PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.26.0 1.25.0 All 54 releases
← All changes | includes/seo/class-schema-management-system.php +83 -39 2.10.0 → 2.13.0 View file →
@@ -1325,9 +1325,9 @@
1325 1325 $this->cache_manager->invalidate_all_cache();
1326 1326 }
1327 1327
1328 1328 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1329 - if ($success && !empty($settings['auto_deploy'])) {
1329 + if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1330 1330 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1331 1331 }
1332 1332
1333 1333 return $success;
@@ -1332,9 +1332,35 @@
1332 1332
1333 1333 return $success;
1334 1334 }
1335 1335
1336 +
1336 1337 /**
1338 + * Whether a save should redeploy the schema it changed.
1339 + *
1340 + * `auto_deploy` is a stored setting (on by default), not something a save
1341 + * restates. Reading it off the incoming patch meant a partial save — which
1342 + * is what the admin screen sends, one field at a time — skipped
1343 + * auto-deploy on a site that had it switched on, and the deployed snapshot
1344 + * the front end serves kept the name, logo and sameAs it was deployed
1345 + * with, however often the user saved (#904, the gate #12 left in place).
1346 + *
1347 + * A patch that does carry the key still wins, so a caller can deploy or
1348 + * hold deliberately.
1349 + *
1350 + * @param array $patch Settings being saved
1351 + * @param array $stored Settings as stored, after the save
1352 + * @return bool
1353 + */
1354 + public static function should_auto_deploy(array $patch, array $stored): bool {
1355 + if (array_key_exists('auto_deploy', $patch)) {
1356 + return !empty($patch['auto_deploy']);
1357 + }
1358 +
1359 + return !empty($stored['auto_deploy']);
1360 + }
1361 +
1362 + /**
1337 1363 * Auto-deploy schema when settings change
1338 1364 *
1339 1365 * Automatically regenerates and deploys schema markup when organization or other
1340 1366 * schema settings are modified, ensuring the frontend output stays in sync.
@@ -1629,8 +1655,19 @@
1629 1655 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1630 1656 // Post/page/product data
1631 1657 $post = get_post($context_id);
1632 1658 if ($post) {
1659 + // Resolve the featured image's URL to its ID here, where the ID
1660 + // is in hand, so the schema builder does not query for an
1661 + // attachment it was just given (#847). Offered as a hint rather
1662 + // than asserted: `post_thumbnail_url` can swap the URL for one
1663 + // the featured image does not own.
1664 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1665 +
1666 + if ($thumbnail_url) {
1667 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1668 + }
1669 +
1633 1670 $content_data = [
1634 1671 'title' => $post->post_title,
1635 1672 'url' => get_permalink($post->ID),
1636 1673 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
@@ -1644,9 +1681,9 @@
1644 1681 // Google rejects as "Invalid value in field datePublished"
1645 1682 // and drops the Article rich result (#465).
1646 1683 'date' => get_the_date('c', $post),
1647 1684 'modified' => get_the_modified_date('c', $post),
1648 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1685 + 'image' => $thumbnail_url,
1649 1686 'focus_keywords' => Focus_Keywords::get($post->ID),
1650 1687 'business_data' => $this->get_business_data_from_local_seo(),
1651 1688 'site_data' => $this->get_site_data_for_schema(),
1652 1689 'social_data' => $this->get_social_data_for_schema()
@@ -2039,12 +2076,10 @@
2039 2076
2040 2077 /**
2041 2078 * Get deployed schemas for frontend integration
2042 2079 *
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
2080 + * Returns the newest active, deployed row of each schema type for the
2081 + * context. Results are cached per context (see Schema_Cache_Manager).
2047 2082 *
2048 2083 * @since 1.0.0
2049 2084 *
2050 2085 * @param string $context_type Context type
@@ -2067,48 +2102,57 @@
2067 2102
2068 2103 // Use existing seo_schema table
2069 2104 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2070 2105
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)
2106 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2107 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2108 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2109 + // a syntax error on every page view and no deployed schema is ever
2110 + // output. A row is the newest of its type when no other row of the
2111 + // same context and type outranks it, so NOT EXISTS keeps the
2112 + // greatest-per-group in the database, and schema_data — JSON, and
2113 + // large — is only transferred for the rows that are output.
2114 + //
2115 + // `<=>` is NULL-safe equality: the site context stores context_id
2116 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2117 + // schema_id breaks a same-second tie, which the window function
2118 + // left to chance.
2119 + $args = [$context_type];
2073 2120 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 - );
2121 + $context_where = 's.context_id IS NULL';
2088 2122 } 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 - );
2123 + $context_where = 's.context_id = %d';
2124 + $args[] = $context_id;
2104 2125 }
2105 2126
2127 + $sql = sprintf(
2128 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2129 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2130 + . ' AND NOT EXISTS ('
2131 + . 'SELECT 1 FROM %1$s n'
2132 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2133 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2134 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2135 + . ')'
2136 + . ' ORDER BY s.schema_type',
2137 + $table_name,
2138 + $context_where
2139 + );
2140 +
2141 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2142 + $deployed_schemas = $wpdb->get_results(
2143 + $wpdb->prepare(
2144 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2145 + $sql,
2146 + ...$args
2147 + ),
2148 + ARRAY_A
2149 + );
2150 +
2106 2151 // Deliberately no early return on an empty result: it has to reach the
2107 2152 // cache write below. Most URLs have no deployed schema, so gating the
2108 2153 // 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).
2154 + // permanent cache misses, re-running the query on every pageview (#392).
2111 2155 $deployed_schemas = $deployed_schemas ?: [];
2112 2156
2113 2157 // Process schemas for return
2114 2158 $processed_schemas = [];