wpdb = $wpdb; $this->table = $wpdb->prefix . 'aicommerce_product_cache'; } /** * Compute a hash representing the current state of a product. * * @param \WC_Product $product The WooCommerce product. * * @return string SHA-256 hash. */ public function compute_hash( \WC_Product $product ): string { $data = array( 'name' => $product->get_name(), 'description' => $product->get_description(), 'short_description' => $product->get_short_description(), 'sku' => $product->get_sku(), 'price' => $product->get_price(), 'regular_price' => $product->get_regular_price(), 'sale_price' => $product->get_sale_price(), 'stock_status' => $product->get_stock_status(), 'stock_quantity' => $product->get_stock_quantity(), 'weight' => $product->get_weight(), 'image_id' => $product->get_image_id(), 'gallery_ids' => $product->get_gallery_image_ids(), 'category_ids' => $product->get_category_ids(), 'tag_ids' => $product->get_tag_ids(), 'brand' => $product->get_meta( '_aicommerce_brand' ), 'gtin' => $product->get_meta( '_aicommerce_gtin' ), 'mpn' => $product->get_meta( '_aicommerce_mpn' ), 'date_modified' => $product->get_date_modified() ? $product->get_date_modified()->getTimestamp() : 0, ); return hash( 'sha256', wp_json_encode( $data ) ); } /** * Get the stored hash for a product and feed type. * * @param int $product_id The product ID. * @param string $feed_type The feed type slug. * * @return string|null Stored hash or null if not cached. */ public function get_stored_hash( int $product_id, string $feed_type = '' ): ?string { // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT data_hash FROM {$this->table} WHERE product_id = %d AND feed_type = %s", $product_id, $feed_type ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return $row ? $row->data_hash : null; } /** * Check if a product is dirty for a given feed type. * * @param int $product_id The product ID. * @param string $feed_type The feed type slug. * * @return bool True if the product needs re-export. */ public function is_dirty( int $product_id, string $feed_type ): bool { $dirty_set = $this->get_dirty_transient( $feed_type ); if ( is_array( $dirty_set ) && in_array( $product_id, $dirty_set, true ) ) { return true; } // No stored hash means never exported — dirty. $stored = $this->get_stored_hash( $product_id, $feed_type ); return null === $stored; } /** * Mark a product as dirty for specified (or all) feed types. * * @param int $product_id The product ID. * @param array $feed_types Feed type slugs. Empty = all registered types. * * @return void */ public function mark_dirty( int $product_id, array $feed_types = array() ): void { if ( empty( $feed_types ) ) { $feed_types = $this->get_registered_feed_types(); } foreach ( $feed_types as $feed_type ) { $dirty_set = $this->get_dirty_transient( $feed_type ); if ( ! is_array( $dirty_set ) ) { $dirty_set = array(); } if ( ! in_array( $product_id, $dirty_set, true ) ) { $dirty_set[] = $product_id; set_transient( self::DIRTY_TRANSIENT_PREFIX . $feed_type, $dirty_set, DAY_IN_SECONDS ); } } } /** * Mark a product as clean for a specific feed type. * * @param int $product_id The product ID. * @param string $feed_type The feed type slug. * @param string $hash The computed data hash. * * @return void */ public function mark_clean( int $product_id, string $feed_type, string $hash ): void { // Remove from dirty transient. $dirty_set = $this->get_dirty_transient( $feed_type ); if ( is_array( $dirty_set ) ) { $dirty_set = array_values( array_diff( $dirty_set, array( $product_id ) ) ); set_transient( self::DIRTY_TRANSIENT_PREFIX . $feed_type, $dirty_set, DAY_IN_SECONDS ); } // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->replace( $this->table, array( 'product_id' => $product_id, 'feed_type' => $feed_type, 'data_hash' => $hash, 'last_synced_at' => current_time( 'mysql' ), ), array( '%d', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Get all dirty product IDs for a feed type. * * @param string $feed_type The feed type slug. * @param int $limit Max IDs to return. 0 = unlimited. * * @return array Array of product IDs needing re-export. */ public function get_dirty_products( string $feed_type, int $limit = 0 ): array { $dirty_set = $this->get_dirty_transient( $feed_type ); $dirty_ids = is_array( $dirty_set ) ? $dirty_set : array(); if ( $limit > 0 && count( $dirty_ids ) >= $limit ) { return array_slice( $dirty_ids, 0, $limit ); } return $dirty_ids; } /** * Update the stored hash and feed data for a product. * * @param int $product_id The product ID. * @param string $hash The computed data hash. * @param array $feed_data The normalized feed data to cache. * @param string $feed_type The feed type slug. * * @return void */ public function update_cache( int $product_id, string $hash, array $feed_data, string $feed_type = '' ): void { // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->replace( $this->table, array( 'product_id' => $product_id, 'feed_type' => $feed_type, 'data_hash' => $hash, 'feed_data' => wp_json_encode( $feed_data ), 'last_synced_at' => current_time( 'mysql' ), ), array( '%d', '%s', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Batch update cache for multiple products. * * @param array $entries Array of ['product_id' => int, 'hash' => string, 'feed_data' => array]. * @param string $feed_type The feed type slug. * * @return void */ public function update_cache_batch( array $entries, string $feed_type = '' ): void { if ( empty( $entries ) ) { return; } $now = current_time( 'mysql' ); $values = array(); foreach ( $entries as $entry ) { $values[] = $this->wpdb->prepare( '(%d, %s, %s, %s, %s)', $entry['product_id'], $feed_type, $entry['hash'], wp_json_encode( $entry['feed_data'] ), $now ); } $values_sql = implode( ', ', $values ); // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->query( "INSERT INTO {$this->table} (product_id, feed_type, data_hash, feed_data, last_synced_at) VALUES {$values_sql} ON DUPLICATE KEY UPDATE data_hash = VALUES(data_hash), feed_data = VALUES(feed_data), last_synced_at = VALUES(last_synced_at)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Get cached feed data for a product. * * @param int $product_id The product ID. * @param string $feed_type The feed type slug. * * @return array|null Cached feed data or null. */ public function get_cached_data( int $product_id, string $feed_type = '' ): ?array { // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT feed_data FROM {$this->table} WHERE product_id = %d AND feed_type = %s", $product_id, $feed_type ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( ! $row || empty( $row->feed_data ) ) { return null; } $data = json_decode( $row->feed_data, true ); return is_array( $data ) ? $data : null; } /** * Remove cache entries for a product across all feed types. * * @param int $product_id The product ID. * * @return void */ public function invalidate( int $product_id ): void { // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->delete( $this->table, array( 'product_id' => $product_id ), array( '%d' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->mark_dirty( $product_id ); } /** * Clear all dirty transients for a feed type. * * @param string $feed_type The feed type slug. * * @return void */ public function clear_dirty( string $feed_type ): void { delete_transient( self::DIRTY_TRANSIENT_PREFIX . $feed_type ); } /** * Get the dirty transient value for a feed type. * * @param string $feed_type The feed type slug. * * @return array|false Array of dirty product IDs or false. */ private function get_dirty_transient( string $feed_type ) { return get_transient( self::DIRTY_TRANSIENT_PREFIX . $feed_type ); } /** * Get registered feed types from options. * * @return array Feed type slugs. */ private function get_registered_feed_types(): array { $types = get_option( 'aicommerce_registered_feed_types', array() ); return is_array( $types ) ? $types : array(); } }