| @@ -40,8 +40,17 @@ | ||
| 40 | 40 | * Legacy baseline of postmeta keys covered by the product/variation digest. Must include every key the |
| 41 | 41 | * sql-bypass fixture mutates (_price and _regular_price today — see |
| 42 | 42 | * class-fixtures-controller.php sql_bypass()) plus the keys a |
| 43 | 43 | * hook-bypassing import/inventory tool plausibly touches. |
| 44 | + * | |
| 45 | + * Changing the MEMBERSHIP of this set moves two things, with two different | |
| 46 | + * levers (ADR 0036): the STORED digests rebuild automatically — this set | |
| 47 | + * feeds {@see digest_formula_fingerprint}, whose move schedules the guarded | |
| 48 | + * product-digest rebuild — and the digests CLIENTS hold go stale, which is | |
| 49 | + * what bumping Config_Fingerprint::PAYLOAD_CONTRACT_VERSION for BOTH owners | |
| 50 | + * (products AND variations — the set is shared) in the same commit repairs. | |
| 51 | + * Reordering without a membership change is a no-op everywhere (SQL sorts). | |
| 52 | + * The fingerprint tests pin the semantic set. | |
| 44 | 53 | */ |
| 45 | 54 | public const DIGESTED_META_KEYS = array( '_global_unique_id', '_price', '_regular_price', '_sale_price', '_sku', '_stock', '_stock_status' ); |
| 46 | 55 | |
| 47 | 56 | /** |
| @@ -58,8 +67,17 @@ | ||
| 58 | 67 | * meta joins them at runtime in {@see customer_digest_select_sql} (the prefix is per-site, so it |
| 59 | 68 | * cannot live in a const): roles are part of the served record under #1379, and a hookless |
| 60 | 69 | * capabilities write (direct update_user_meta/SQL/import) must drift the digest so the |
| 61 | 70 | * integrity scan can repair the stale role — no role/profile hook fires for those writes. |
| 71 | + * | |
| 72 | + * Changing the MEMBERSHIP of this set (ADR 0036): bump | |
| 73 | + * Config_Fingerprint::PAYLOAD_CONTRACT_VERSION for `customers` in the same | |
| 74 | + * commit so clients re-pull — and know that the STORED customer digests have | |
| 75 | + * NO automatic rebuild trigger today: {@see digest_formula_fingerprint} folds | |
| 76 | + * the product keys only, so the scan reports store-wide false customer drift | |
| 77 | + * until a manual rebuild. Extending the rebuild trigger to this set is | |
| 78 | + * #1756 phase 2/3 work; until it lands, a change here also needs a | |
| 79 | + * deliberate rebuild plan. The fingerprint tests pin the semantic set. | |
| 62 | 80 | */ |
| 63 | 81 | public const CUSTOMER_DIGESTED_META_KEYS = array( 'first_name', 'last_name', 'billing_email', 'billing_phone' ); |
| 64 | 82 | |
| 65 | 83 | /** |
| @@ -65,8 +83,14 @@ | ||
| 65 | 83 | /** |
| 66 | 84 | * Order postmeta folded into the digest under the CPT (legacy) storage path (ADR 0015, Leg-3 phase 7). |
| 67 | 85 | * Under HPOS these live as wc_orders COLUMNS (total_amount, customer_id) so no meta join is needed; |
| 68 | 86 | * this allowlist only applies to the wp_posts fallback. Kept minimal — existence/identity signal. |
| 87 | + * | |
| 88 | + * Changing the MEMBERSHIP of this set (ADR 0036): bump | |
| 89 | + * Config_Fingerprint::PAYLOAD_CONTRACT_VERSION for `orders` in the same | |
| 90 | + * commit. Like the customer set, it has NO automatic stored-digest rebuild | |
| 91 | + * trigger today ({@see digest_formula_fingerprint} folds product keys only) — | |
| 92 | + * #1756 phase 2/3 owns that. The fingerprint tests pin the semantic set. | |
| 69 | 93 | */ |
| 70 | 94 | public const ORDER_DIGESTED_META_KEYS = array( '_order_total', '_customer_user' ); |
| 71 | 95 | |
| 72 | 96 | /** |
| @@ -224,10 +248,9 @@ | ||
| 224 | 248 | // backstop, and CRC32 is linear so structured bulk edits correlate collisions). |
| 225 | 249 | . ')),1,16),16,10) AS UNSIGNED) AS crc' |
| 226 | 250 | . " FROM {$wpdb->posts} p" |
| 227 | 251 | . " LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key IN {$meta_keys_sql}" |
| 228 | - . ' WHERE p.post_type IN ' . self::PRODUCT_POST_TYPES_SQL | |
| 229 | - . ' AND p.post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL | |
| 252 | + . ' WHERE ' . $this->live_product_predicate_sql( 'p' ) | |
| 230 | 253 | . ( '' === $where_sql ? '' : ' AND ' . $where_sql ) |
| 231 | 254 | . ' GROUP BY p.ID'; |
| 232 | 255 | } |
| 233 | 256 | |
| @@ -290,9 +313,9 @@ | ||
| 290 | 313 | . " COALESCE(o.total_amount,'')," |
| 291 | 314 | . ' COALESCE(o.customer_id,0),' |
| 292 | 315 | . " COALESCE(o.date_updated_gmt,''))),1,16),16,10) AS UNSIGNED) AS crc" |
| 293 | 316 | . " FROM {$orders_table} o" |
| 294 | - . " WHERE o.type = 'shop_order' AND o.status NOT IN ('trash','auto-draft')" | |
| 317 | + . ' WHERE ' . $this->live_order_predicate_sql( 'o' ) | |
| 295 | 318 | . $condition; |
| 296 | 319 | } |
| 297 | 320 | |
| 298 | 321 | $meta_keys_sql = "('" . implode( "','", self::ORDER_DIGESTED_META_KEYS ) . "')"; |
| @@ -304,9 +327,9 @@ | ||
| 304 | 327 | . " COALESCE(p.post_modified_gmt,'')," |
| 305 | 328 | . " COALESCE(GROUP_CONCAT(CONCAT(pm.meta_key,'=',COALESCE(pm.meta_value,'')) ORDER BY pm.meta_key ASC, pm.meta_id ASC SEPARATOR '|'),''))),1,16),16,10) AS UNSIGNED) AS crc" |
| 306 | 329 | . " FROM {$wpdb->posts} p" |
| 307 | 330 | . " LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key IN {$meta_keys_sql}" |
| 308 | - . " WHERE p.post_type = 'shop_order' AND p.post_status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL | |
| 331 | + . ' WHERE ' . $this->live_order_predicate_sql( 'p' ) | |
| 309 | 332 | . $condition |
| 310 | 333 | . ' GROUP BY p.ID'; |
| 311 | 334 | } |
| 312 | 335 | |
| @@ -329,8 +352,13 @@ | ||
| 329 | 352 | * @return array<int, string> |
| 330 | 353 | */ |
| 331 | 354 | public function read_digests( string $collection, array $ids ): array { |
| 332 | 355 | global $wpdb; |
| 356 | + // Order/customer upserts are coalesced per request and land at shutdown | |
| 357 | + // (see Integrity_Digest::$pending_digests). A read inside the same | |
| 358 | + // request — the pull lane stamping `_rxdb_digest` after a write — must | |
| 359 | + // see the settled digest, so land what is owed before reading. | |
| 360 | + Integrity_Digest::flush_pending_digests(); | |
| 333 | 361 | $object_types = self::digest_object_types( $collection ); |
| 334 | 362 | if ( array() === $object_types ) { |
| 335 | 363 | return array(); |
| 336 | 364 | } |
| @@ -390,15 +418,15 @@ | ||
| 390 | 418 | $live_rows = isset( $row['digest']['live_rows'] ) ? (string) $row['digest']['live_rows'] : ''; |
| 391 | 419 | // Products are the one collection whose servability is NARROWER than a live |
| 392 | 420 | // row: POS visibility and the readable-catalog scope both apply, so the |
| 393 | 421 | // richer reader owns them. Every other id-space is exactly its live row. |
| 394 | - $products = 'products' === $collection; | |
| 395 | - if ( ! $products && ( '' === $live_rows || ! method_exists( $this, $live_rows ) ) ) { | |
| 422 | + $servable = $row['digest']['servable'] ?? null; | |
| 423 | + if ( null === $servable && ( '' === $live_rows || ! method_exists( $this, $live_rows ) ) ) { | |
| 396 | 424 | return $ids; |
| 397 | 425 | } |
| 398 | 426 | $wpdb->last_error = ''; |
| 399 | - if ( $products ) { | |
| 400 | - $servable_ids = $this->servable_product_ids( $ids, true ); | |
| 427 | + if ( null !== $servable ) { | |
| 428 | + $servable_ids = $this->$servable( $ids, true ); | |
| 401 | 429 | } else { |
| 402 | 430 | $predicate = (string) \call_user_func( array( $this, $live_rows ), 'requested.id' ); |
| 403 | 431 | $requested = implode( ' UNION ALL ', array_fill( 0, \count( $ids ), 'SELECT %d AS id' ) ); |
| 404 | 432 | $servable_ids = $wpdb->get_col( |
| @@ -453,10 +481,9 @@ | ||
| 453 | 481 | public function live_row_exists_sql( string $id_expr ): string { |
| 454 | 482 | global $wpdb; |
| 455 | 483 | |
| 456 | 484 | return "EXISTS (SELECT 1 FROM {$wpdb->posts} lp WHERE lp.ID = {$id_expr}" |
| 457 | - . ' AND lp.post_type IN ' . self::PRODUCT_POST_TYPES_SQL | |
| 458 | - . ' AND lp.post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL . ')'; | |
| 485 | + . ' AND ' . $this->live_product_predicate_sql( 'lp' ) . ')'; | |
| 459 | 486 | } |
| 460 | 487 | |
| 461 | 488 | /** |
| 462 | 489 | * Customer analogue of {@see live_row_exists_sql}: the id still names any WordPress user (ADR 0015). |
| @@ -478,12 +505,12 @@ | ||
| 478 | 505 | global $wpdb; |
| 479 | 506 | if ( $this->orders_are_hpos() ) { |
| 480 | 507 | $orders_table = $wpdb->prefix . 'wc_orders'; |
| 481 | 508 | return "EXISTS (SELECT 1 FROM {$orders_table} lo WHERE lo.id = {$id_expr}" |
| 482 | - . " AND lo.type = 'shop_order' AND lo.status NOT IN ('trash','auto-draft'))"; | |
| 509 | + . ' AND ' . $this->live_order_predicate_sql( 'lo' ) . ')'; | |
| 483 | 510 | } |
| 484 | 511 | return "EXISTS (SELECT 1 FROM {$wpdb->posts} lp WHERE lp.ID = {$id_expr}" |
| 485 | - . " AND lp.post_type = 'shop_order' AND lp.post_status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL . ')'; | |
| 512 | + . ' AND ' . $this->live_order_predicate_sql( 'lp' ) . ')'; | |
| 486 | 513 | } |
| 487 | 514 | |
| 488 | 515 | /** |
| 489 | 516 | * Stored-vs-current bucket aggregate over one id window (the integrity scan). |
| @@ -509,21 +536,18 @@ | ||
| 509 | 536 | global $wpdb; |
| 510 | 537 | $bucket_size = max( 1, (int) ( $range['bucket_size'] ?? 1 ) ); |
| 511 | 538 | $window_start = max( 0, (int) ( $range['start'] ?? 0 ) ); |
| 512 | 539 | $window_end = max( 0, (int) ( $range['end'] ?? 0 ) ); |
| 513 | - $publish = 'products' === $collection && 'publish' === ( $filters['status'] ?? '' ); | |
| 514 | - $object_types = self::OBJECT_TYPES_SQL; | |
| 515 | - $current_sql = $this->row_digest_select_sql( 'p.ID >= %d AND p.ID < %d' ); | |
| 516 | - $max_sql = $this->row_digest_select_sql(); | |
| 517 | - if ( 'customers' === $collection ) { | |
| 518 | - $object_types = "('customer')"; | |
| 519 | - $current_sql = $this->customer_digest_select_sql( 'u.ID >= %d AND u.ID < %d' ); | |
| 520 | - $max_sql = $this->customer_digest_select_sql(); | |
| 521 | - } elseif ( 'orders' === $collection ) { | |
| 522 | - $object_types = "('order')"; | |
| 523 | - $current_sql = $this->order_digest_select_sql( '{id} >= %d AND {id} < %d' ); | |
| 524 | - $max_sql = $this->order_digest_select_sql(); | |
| 540 | + $digest = Collections::row( $collection )['digest'] ?? null; | |
| 541 | + if ( null === $digest ) { | |
| 542 | + return array( | |
| 543 | + 'buckets' => array(), | |
| 544 | + 'max_id' => 0, | |
| 545 | + ); | |
| 525 | 546 | } |
| 547 | + $publish = isset( Collections::row( $collection )['digest']['published_ids'] ) && 'publish' === ( $filters['status'] ?? '' ); | |
| 548 | + $object_types = "('" . implode( "','", $digest['object_types'] ) . "')"; | |
| 549 | + $current_sql = $this->{$digest['select']}( $digest['id_column'] . ' >= %d AND ' . $digest['id_column'] . ' < %d' ); | |
| 526 | 550 | $current_scope = $publish ? $this->product_servable_predicate_sql( 't.id', true ) : array( |
| 527 | 551 | 'sql' => '', |
| 528 | 552 | 'args' => array(), |
| 529 | 553 | ); |
| @@ -530,10 +554,10 @@ | ||
| 530 | 554 | $stored_scope = $publish ? $this->product_servable_predicate_sql( 'd.object_id', true ) : array( |
| 531 | 555 | 'sql' => '', |
| 532 | 556 | 'args' => array(), |
| 533 | 557 | ); |
| 534 | - $current_join = $publish ? " INNER JOIN {$wpdb->posts} catalog_post ON catalog_post.ID = t.id LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent AND catalog_post.post_type = 'product_variation'" : ''; | |
| 535 | - $stored_join = $publish ? " INNER JOIN {$wpdb->posts} catalog_post ON catalog_post.ID = d.object_id LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent AND catalog_post.post_type = 'product_variation'" : ''; | |
| 558 | + $current_join = $publish ? $this->servable_product_join_sql( 't.id' ) : ''; | |
| 559 | + $stored_join = $publish ? $this->servable_product_join_sql( 'd.object_id' ) : ''; | |
| 536 | 560 | |
| 537 | 561 | // Current side: one SQL pass — per-row canonical digests aggregated |
| 538 | 562 | // per bucket inside the DB engine. Raw rows are digested for |
| 539 | 563 | // DETECTION only; hydration goes through filtered REST (ADR 0003). |
| @@ -597,27 +621,18 @@ | ||
| 597 | 621 | 'match' => $stored_count === $current_count && $stored_digest === $current_digest, |
| 598 | 622 | ); |
| 599 | 623 | } |
| 600 | 624 | |
| 601 | - $max_query = | |
| 602 | - 'SELECT GREATEST(' | |
| 603 | - . "COALESCE((SELECT MAX(ID) FROM {$wpdb->posts} WHERE post_type IN " . self::PRODUCT_POST_TYPES_SQL | |
| 604 | - . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL . '), 0),' | |
| 605 | - . ' COALESCE((SELECT MAX(object_id) FROM ' . $this->table_name() | |
| 606 | - . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL . '), 0))'; | |
| 607 | - $max_args = array(); | |
| 608 | - if ( 'products' !== $collection || $publish ) { | |
| 609 | - $live_scope = $publish ? $this->product_servable_predicate_sql( 'live.id', true ) : array( | |
| 610 | - 'sql' => '', | |
| 611 | - 'args' => array(), | |
| 612 | - ); | |
| 613 | - $live_join = $publish ? " INNER JOIN {$wpdb->posts} catalog_post ON catalog_post.ID = live.id LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent AND catalog_post.post_type = 'product_variation'" : ''; | |
| 614 | - $max_query = 'SELECT GREATEST(COALESCE((SELECT MAX(live.id) FROM (' . $max_sql . ') live' . $live_join . ( '' === $live_scope['sql'] ? '' : ' WHERE ' . $live_scope['sql'] ) . '), 0),' | |
| 615 | - . ' COALESCE((SELECT MAX(d.object_id) FROM ' . $this->table_name() . ' d' | |
| 616 | - . ' WHERE d.object_type IN ' . $object_types . '), 0))'; | |
| 617 | - $max_args = $live_scope['args']; | |
| 618 | - } | |
| 619 | - $max_id = (int) $wpdb->get_var( empty( $max_args ) ? $max_query : $wpdb->prepare( $max_query, ...$max_args ) ); | |
| 625 | + // Completion id: the larger of the last LIVE id under the collection's own | |
| 626 | + // servable predicate and the last STORED id. The live side is MAX(id) | |
| 627 | + // straight off the base table — wrapping the un-windowed per-row digest | |
| 628 | + // SELECT as a derived table just to take its max digested the whole | |
| 629 | + // collection on every scan page (0.4–3 s on real stores; #1805, ADR 0038). | |
| 630 | + $live_max = $this->live_max_id_sql( $collection, $publish ); | |
| 631 | + $max_query = 'SELECT GREATEST(COALESCE((' . $live_max['sql'] . '), 0),' | |
| 632 | + . ' COALESCE((SELECT MAX(d.object_id) FROM ' . $this->table_name() . ' d' | |
| 633 | + . ' WHERE d.object_type IN ' . $object_types . '), 0))'; | |
| 634 | + $max_id = (int) $wpdb->get_var( empty( $live_max['args'] ) ? $max_query : $wpdb->prepare( $max_query, ...$live_max['args'] ) ); | |
| 620 | 635 | |
| 621 | 636 | return array( |
| 622 | 637 | 'buckets' => $buckets, |
| 623 | 638 | 'max_id' => $max_id, |
| @@ -640,8 +655,9 @@ | ||
| 640 | 655 | public function bucket_drift( array $range ): array { |
| 641 | 656 | global $wpdb; |
| 642 | 657 | $range_start = max( 0, (int) ( $range['start'] ?? 0 ) ); |
| 643 | 658 | $range_end = max( 0, (int) ( $range['end'] ?? 0 ) ); |
| 659 | + $digest = Collections::row( 'products' )['digest']; | |
| 644 | 660 | $table = $this->table_name(); |
| 645 | 661 | |
| 646 | 662 | // Same-formula invariant: the current side must digest identically to the stored side. |
| 647 | 663 | $this->raise_group_concat_max_len(); |
| @@ -649,17 +665,17 @@ | ||
| 649 | 665 | $wpdb->prepare( |
| 650 | 666 | 'SELECT cur.id AS id,' |
| 651 | 667 | . " CASE WHEN d.digest IS NULL THEN 'missing_stored' ELSE 'changed' END AS status," |
| 652 | 668 | . ' d.digest AS stored_digest, cur.crc AS current_digest, cur.object_type AS object_type' |
| 653 | - . ' FROM (' . $this->row_digest_select_sql( 'p.ID >= %d AND p.ID < %d' ) . ') cur' | |
| 669 | + . ' FROM (' . $this->{$digest['select']}( $digest['id_column'] . ' >= %d AND ' . $digest['id_column'] . ' < %d' ) . ') cur' | |
| 654 | 670 | . " LEFT JOIN {$table} d ON d.object_id = cur.id AND d.object_type = cur.object_type" |
| 655 | 671 | . ' WHERE d.digest IS NULL OR d.digest <> cur.crc' |
| 656 | 672 | . ' UNION ALL' |
| 657 | 673 | . " SELECT d.object_id AS id, 'deleted' AS status, d.digest AS stored_digest, NULL AS current_digest, d.object_type AS object_type" |
| 658 | 674 | . " FROM {$table} d" |
| 659 | - . ' WHERE d.object_type IN ' . self::OBJECT_TYPES_SQL | |
| 675 | + . ' WHERE d.object_type IN ' . "('" . implode( "','", $digest['object_types'] ) . "')" | |
| 660 | 676 | . ' AND d.object_id >= %d AND d.object_id < %d' |
| 661 | - . ' AND NOT ' . $this->live_row_exists_sql( 'd.object_id' ) | |
| 677 | + . ' AND NOT ' . $this->{$digest['live_rows']}( 'd.object_id' ) | |
| 662 | 678 | . ' ORDER BY id ASC', |
| 663 | 679 | $range_start, |
| 664 | 680 | $range_end, |
| 665 | 681 | $range_start, |
| @@ -710,15 +726,14 @@ | ||
| 710 | 726 | |
| 711 | 727 | $servable_join = ''; |
| 712 | 728 | $servable_filter = ''; |
| 713 | 729 | $servable_args = array(); |
| 714 | - if ( 'customers' === $collection ) { | |
| 715 | - $inner_sql = $this->customer_digest_select_sql( 'u.ID >= %d AND u.ID < %d' ); | |
| 716 | - } elseif ( 'orders' === $collection ) { | |
| 717 | - // Orders bucket over their own id-space (HPOS o.id / CPT p.ID) via the {id} placeholder. | |
| 718 | - $inner_sql = $this->order_digest_select_sql( '{id} >= %d AND {id} < %d' ); | |
| 719 | - } else { | |
| 720 | - $inner_sql = $this->row_digest_select_sql( 'p.ID >= %d AND p.ID < %d' ); | |
| 730 | + $digest = Collections::row( $collection )['digest'] ?? null; | |
| 731 | + if ( null === $digest ) { | |
| 732 | + return array(); | |
| 733 | + } | |
| 734 | + $inner_sql = $this->{$digest['select']}( $digest['id_column'] . ' >= %d AND ' . $digest['id_column'] . ' < %d' ); | |
| 735 | + if ( isset( $digest['servable'] ) ) { | |
| 721 | 736 | $servable_join = " INNER JOIN {$wpdb->posts} catalog_post ON catalog_post.ID = cur.id"; |
| 722 | 737 | if ( 'publish' === ( $filters['status'] ?? '' ) ) { |
| 723 | 738 | $servable_join .= " LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent" |
| 724 | 739 | . " AND catalog_post.post_type = 'product_variation'"; |
| @@ -814,11 +829,10 @@ | ||
| 814 | 829 | public function needs_product_rebuild(): bool { |
| 815 | 830 | global $wpdb; |
| 816 | 831 | |
| 817 | 832 | return (bool) $wpdb->get_var( |
| 818 | - 'SELECT EXISTS (SELECT 1 FROM ' . $wpdb->posts | |
| 819 | - . ' WHERE post_type IN ' . self::PRODUCT_POST_TYPES_SQL | |
| 820 | - . ' AND post_status NOT IN ' . self::EXCLUDED_POST_STATUSES_SQL . ' LIMIT 1)' | |
| 833 | + 'SELECT EXISTS (SELECT 1 FROM ' . $wpdb->posts . ' p' | |
| 834 | + . ' WHERE ' . $this->live_product_predicate_sql( 'p' ) . ' LIMIT 1)' | |
| 821 | 835 | . ' AND NOT EXISTS (SELECT 1 FROM ' . $this->table_name() |
| 822 | 836 | . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL . ' LIMIT 1)' |
| 823 | 837 | ); |
| 824 | 838 | } |
| @@ -830,8 +844,115 @@ | ||
| 830 | 844 | private function published_product_predicate_sql( string $post_alias, string $parent_alias ): string { |
| 831 | 845 | return "(({$post_alias}.post_type = 'product' AND {$post_alias}.post_status = 'publish')" |
| 832 | 846 | . " OR ({$post_alias}.post_type = 'product_variation' AND {$parent_alias}.post_type = 'product'" |
| 833 | 847 | . " AND {$parent_alias}.post_status = 'publish'))"; |
| 848 | + } | |
| 849 | + | |
| 850 | + /** | |
| 851 | + * A LIVE product-space row over a `wp_posts` alias: a product or variation that | |
| 852 | + * is not trashed/auto-draft. ONE spelling for the digest SELECT, the live-row | |
| 853 | + * probes, the rebuild guard and the completion id — the scan is only sound | |
| 854 | + * while every side agrees on what "live" means. | |
| 855 | + */ | |
| 856 | + private function live_product_predicate_sql( string $alias ): string { | |
| 857 | + return "{$alias}.post_type IN " . self::PRODUCT_POST_TYPES_SQL | |
| 858 | + . " AND {$alias}.post_status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL; | |
| 859 | + } | |
| 860 | + | |
| 861 | + /** | |
| 862 | + * A LIVE order over the active order store's alias — `wc_orders` under HPOS, | |
| 863 | + * `wp_posts` under legacy CPT — with the same one-spelling rule as | |
| 864 | + * {@see live_product_predicate_sql}. | |
| 865 | + */ | |
| 866 | + private function live_order_predicate_sql( string $alias ): string { | |
| 867 | + if ( $this->orders_are_hpos() ) { | |
| 868 | + return "{$alias}.type = 'shop_order' AND {$alias}.status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL; | |
| 869 | + } | |
| 870 | + | |
| 871 | + return "{$alias}.post_type = 'shop_order' AND {$alias}.post_status NOT IN " . self::EXCLUDED_POST_STATUSES_SQL; | |
| 872 | + } | |
| 873 | + | |
| 874 | + /** | |
| 875 | + * The joins {@see product_servable_predicate_sql} reads through: the post row | |
| 876 | + * behind `$id_expr` as `catalog_post`, and its parent as `parent_product` when | |
| 877 | + * it is a variation. One spelling for every side of the scan. | |
| 878 | + */ | |
| 879 | + private function servable_product_join_sql( string $id_expr ): string { | |
| 880 | + global $wpdb; | |
| 881 | + | |
| 882 | + return " INNER JOIN {$wpdb->posts} catalog_post ON catalog_post.ID = {$id_expr}" . $this->parent_product_join_sql(); | |
| 883 | + } | |
| 884 | + | |
| 885 | + /** | |
| 886 | + * `catalog_post`'s parent as `parent_product` when it is a variation (NULL otherwise). | |
| 887 | + */ | |
| 888 | + private function parent_product_join_sql(): string { | |
| 889 | + global $wpdb; | |
| 890 | + | |
| 891 | + return " LEFT JOIN {$wpdb->posts} parent_product ON parent_product.ID = catalog_post.post_parent AND catalog_post.post_type = 'product_variation'"; | |
| 892 | + } | |
| 893 | + | |
| 894 | + /** | |
| 895 | + * `MAX(id)` of a collection's LIVE rows under the same predicate its digest | |
| 896 | + * SELECT uses — off the base table, never through a digested row (#1805). | |
| 897 | + * | |
| 898 | + * Customers are every `wp_users` row (#1379), so this is the primary key's end. | |
| 899 | + * Orders and products carry a type/status predicate, so this is one index pass | |
| 900 | + * over the live rows of that type — the honest floor, since no index ends on | |
| 901 | + * the id under a status filter. Under the published product scope the servable | |
| 902 | + * predicate (published, or a variation of a published parent, and not POS-hidden) | |
| 903 | + * applies to the post row itself, exactly as the windowed sides apply it. | |
| 904 | + * | |
| 905 | + * @return array{sql: string, args: array<int, int>} | |
| 906 | + */ | |
| 907 | + private function live_max_id_sql( string $collection, bool $publish ): array { | |
| 908 | + $digest = Collections::row( $collection )['digest'] ?? null; | |
| 909 | + if ( null === $digest ) { | |
| 910 | + return array( | |
| 911 | + 'sql' => 'SELECT NULL WHERE 1 = 0', | |
| 912 | + 'args' => array(), | |
| 913 | + ); | |
| 914 | + } | |
| 915 | + return $this->{$digest['live_max']}( $publish ); | |
| 916 | + } | |
| 917 | + | |
| 918 | + private function customer_live_max_id_sql(): array { | |
| 919 | + global $wpdb; | |
| 920 | + return array( | |
| 921 | + 'sql' => "SELECT MAX(u.ID) FROM {$wpdb->users} u", | |
| 922 | + 'args' => array(), | |
| 923 | + ); | |
| 924 | + } | |
| 925 | + | |
| 926 | + private function order_live_max_id_sql(): array { | |
| 927 | + global $wpdb; | |
| 928 | + if ( $this->orders_are_hpos() ) { | |
| 929 | + $orders_table = $wpdb->prefix . 'wc_orders'; | |
| 930 | + return array( | |
| 931 | + 'sql' => "SELECT MAX(o.id) FROM {$orders_table} o WHERE " . $this->live_order_predicate_sql( 'o' ), | |
| 932 | + 'args' => array(), | |
| 933 | + ); | |
| 934 | + } | |
| 935 | + return array( | |
| 936 | + 'sql' => "SELECT MAX(p.ID) FROM {$wpdb->posts} p WHERE " . $this->live_order_predicate_sql( 'p' ), | |
| 937 | + 'args' => array(), | |
| 938 | + ); | |
| 939 | + } | |
| 940 | + | |
| 941 | + private function product_live_max_id_sql( bool $publish ): array { | |
| 942 | + global $wpdb; | |
| 943 | + if ( ! $publish ) { | |
| 944 | + return array( | |
| 945 | + 'sql' => "SELECT MAX(p.ID) FROM {$wpdb->posts} p WHERE " . $this->live_product_predicate_sql( 'p' ), | |
| 946 | + 'args' => array(), | |
| 947 | + ); | |
| 948 | + } | |
| 949 | + $scope = $this->product_servable_predicate_sql( 'catalog_post.ID', true ); | |
| 950 | + return array( | |
| 951 | + 'sql' => "SELECT MAX(catalog_post.ID) FROM {$wpdb->posts} catalog_post" . $this->parent_product_join_sql() | |
| 952 | + . ' WHERE ' . $scope['sql'], | |
| 953 | + 'args' => $scope['args'], | |
| 954 | + ); | |
| 834 | 955 | } |
| 835 | 956 | |
| 836 | 957 | private function product_servable_predicate_sql( string $id_expr, bool $publish ): array { |
| 837 | 958 | $predicates = array( |