PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 All 56 releases
← All changes | includes/seo/class-schema-management-system.php +105 -41 2.10.0 → 2.14.1 View file →
@@ -730,8 +730,27 @@
730 730 return $optimization;
731 731 }
732 732
733 733 /**
734 + * Deployed types an authoritative deploy takes off the page.
735 + *
736 + * Everything deployed that the payload left out, except the types the
737 + * caller sent but could not deploy: those failed validation, they were not
738 + * removed by the user, so their live copy stays. Retiring them took the
739 + * site's Organization down behind a success toast (#949).
740 + *
741 + * @since 2.14.1
742 + *
743 + * @param string[] $deployed Types deployed for the context now.
744 + * @param string[] $payload Types in this deploy.
745 + * @param string[] $retain_types Types sent but skipped by validation.
746 + * @return string[] Types to retire.
747 + */
748 + public static function types_to_retire(array $deployed, array $payload, array $retain_types = []): array {
749 + return array_values(array_diff($deployed, $payload, $retain_types));
750 + }
751 +
752 + /**
734 753 * Deploy schema markup with automated implementation
735 754 *
736 755 * @since 1.0.0
737 756 *
@@ -765,11 +784,12 @@
765 784 if (!empty($options['authoritative'])) {
766 785 $deployment['retired_schemas'] = $this->retire_schema_types(
767 786 $context_type,
768 787 $context_id,
769 - array_diff(
788 + self::types_to_retire(
770 789 array_keys($this->get_deployed_schemas($context_type, $context_id)),
771 - array_keys($schema_data)
790 + array_keys($schema_data),
791 + (array) ($options['retain_types'] ?? [])
772 792 )
773 793 );
774 794 }
775 795
@@ -1325,9 +1345,9 @@
1325 1345 $this->cache_manager->invalidate_all_cache();
1326 1346 }
1327 1347
1328 1348 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1329 - if ($success && !empty($settings['auto_deploy'])) {
1349 + if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1330 1350 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1331 1351 }
1332 1352
1333 1353 return $success;
@@ -1332,9 +1352,35 @@
1332 1352
1333 1353 return $success;
1334 1354 }
1335 1355
1356 +
1336 1357 /**
1358 + * Whether a save should redeploy the schema it changed.
1359 + *
1360 + * `auto_deploy` is a stored setting (on by default), not something a save
1361 + * restates. Reading it off the incoming patch meant a partial save — which
1362 + * is what the admin screen sends, one field at a time — skipped
1363 + * auto-deploy on a site that had it switched on, and the deployed snapshot
1364 + * the front end serves kept the name, logo and sameAs it was deployed
1365 + * with, however often the user saved (#904, the gate #12 left in place).
1366 + *
1367 + * A patch that does carry the key still wins, so a caller can deploy or
1368 + * hold deliberately.
1369 + *
1370 + * @param array $patch Settings being saved
1371 + * @param array $stored Settings as stored, after the save
1372 + * @return bool
1373 + */
1374 + public static function should_auto_deploy(array $patch, array $stored): bool {
1375 + if (array_key_exists('auto_deploy', $patch)) {
1376 + return !empty($patch['auto_deploy']);
1377 + }
1378 +
1379 + return !empty($stored['auto_deploy']);
1380 + }
1381 +
1382 + /**
1337 1383 * Auto-deploy schema when settings change
1338 1384 *
1339 1385 * Automatically regenerates and deploys schema markup when organization or other
1340 1386 * schema settings are modified, ensuring the frontend output stays in sync.
@@ -1629,8 +1675,19 @@
1629 1675 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1630 1676 // Post/page/product data
1631 1677 $post = get_post($context_id);
1632 1678 if ($post) {
1679 + // Resolve the featured image's URL to its ID here, where the ID
1680 + // is in hand, so the schema builder does not query for an
1681 + // attachment it was just given (#847). Offered as a hint rather
1682 + // than asserted: `post_thumbnail_url` can swap the URL for one
1683 + // the featured image does not own.
1684 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1685 +
1686 + if ($thumbnail_url) {
1687 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1688 + }
1689 +
1633 1690 $content_data = [
1634 1691 'title' => $post->post_title,
1635 1692 'url' => get_permalink($post->ID),
1636 1693 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
@@ -1644,9 +1701,9 @@
1644 1701 // Google rejects as "Invalid value in field datePublished"
1645 1702 // and drops the Article rich result (#465).
1646 1703 'date' => get_the_date('c', $post),
1647 1704 'modified' => get_the_modified_date('c', $post),
1648 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1705 + 'image' => $thumbnail_url,
1649 1706 'focus_keywords' => Focus_Keywords::get($post->ID),
1650 1707 'business_data' => $this->get_business_data_from_local_seo(),
1651 1708 'site_data' => $this->get_site_data_for_schema(),
1652 1709 'social_data' => $this->get_social_data_for_schema()
@@ -2039,12 +2096,10 @@
2039 2096
2040 2097 /**
2041 2098 * Get deployed schemas for frontend integration
2042 2099 *
2043 - * PERFORMANCE OPTIMIZED: This method now uses:
2044 - * 1. Schema caching layer (90% reduction in database queries)
2045 - * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
2046 - * 3. Composite index: idx_context_schema_active
2100 + * Returns the newest active, deployed row of each schema type for the
2101 + * context. Results are cached per context (see Schema_Cache_Manager).
2047 2102 *
2048 2103 * @since 1.0.0
2049 2104 *
2050 2105 * @param string $context_type Context type
@@ -2067,48 +2122,57 @@
2067 2122
2068 2123 // Use existing seo_schema table
2069 2124 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2070 2125
2071 - // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
2072 - // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
2126 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2127 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2128 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2129 + // a syntax error on every page view and no deployed schema is ever
2130 + // output. A row is the newest of its type when no other row of the
2131 + // same context and type outranks it, so NOT EXISTS keeps the
2132 + // greatest-per-group in the database, and schema_data — JSON, and
2133 + // large — is only transferred for the rows that are output.
2134 + //
2135 + // `<=>` is NULL-safe equality: the site context stores context_id
2136 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2137 + // schema_id breaks a same-second tie, which the window function
2138 + // left to chance.
2139 + $args = [$context_type];
2073 2140 if (null === $context_id) {
2074 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2075 - $sql = sprintf(
2076 - 'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id IS NULL AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type',
2077 - $table_name
2078 - );
2079 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2080 - $deployed_schemas = $wpdb->get_results(
2081 - $wpdb->prepare(
2082 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2083 - $sql,
2084 - $context_type
2085 - ),
2086 - ARRAY_A
2087 - );
2141 + $context_where = 's.context_id IS NULL';
2088 2142 } else {
2089 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2090 - $sql = sprintf(
2091 - 'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id = %%d AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type',
2092 - $table_name
2093 - );
2094 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2095 - $deployed_schemas = $wpdb->get_results(
2096 - $wpdb->prepare(
2097 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2098 - $sql,
2099 - $context_type,
2100 - $context_id
2101 - ),
2102 - ARRAY_A
2103 - );
2143 + $context_where = 's.context_id = %d';
2144 + $args[] = $context_id;
2104 2145 }
2105 2146
2147 + $sql = sprintf(
2148 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2149 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2150 + . ' AND NOT EXISTS ('
2151 + . 'SELECT 1 FROM %1$s n'
2152 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2153 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2154 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2155 + . ')'
2156 + . ' ORDER BY s.schema_type',
2157 + $table_name,
2158 + $context_where
2159 + );
2160 +
2161 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2162 + $deployed_schemas = $wpdb->get_results(
2163 + $wpdb->prepare(
2164 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2165 + $sql,
2166 + ...$args
2167 + ),
2168 + ARRAY_A
2169 + );
2170 +
2106 2171 // Deliberately no early return on an empty result: it has to reach the
2107 2172 // cache write below. Most URLs have no deployed schema, so gating the
2108 2173 // write on a non-empty result made the majority of front-end requests
2109 - // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY
2110 - // ...) query with two filesorts on every pageview (#392).
2174 + // permanent cache misses, re-running the query on every pageview (#392).
2111 2175 $deployed_schemas = $deployed_schemas ?: [];
2112 2176
2113 2177 // Process schemas for return
2114 2178 $processed_schemas = [];