*/ private const REMOVED_INDEXES = array( 'idx_provider_model', 'idx_object', 'idx_content_hash' ); // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. /** * Ensures the embeddings table matches the current schema version. * * @since 1.4.0 */ public function maybe_upgrade_table(): void { if ( $this->is_version_current() && $this->table_exists() ) { return; } if ( $this->table_exists() ) { $this->migrate_existing_table(); } else { $this->create_table(); } if ( ! $this->table_exists() || ! $this->has_current_indexes() ) { return; } update_option( self::SCHEMA_VERSION_OPTION, self::SCHEMA_VERSION, true ); } /** * Checks whether the stored schema version is the current one. * * @since 1.5.0 * * @return bool True when the stored version is current. */ public function is_version_current(): bool { return self::SCHEMA_VERSION === get_option( self::SCHEMA_VERSION_OPTION, '' ); } /** * Returns the names of the table's indexes. * * @since 1.5.0 * * @return array Index names as keys. */ public function get_index_names(): array { global $wpdb; $table = $this->get_table_name(); $rows = $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $names = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { if ( ! is_array( $row ) || ! isset( $row['Key_name'] ) ) { continue; } $names[ (string) $row['Key_name'] ] = true; } return $names; } /** * Creates the database table if needed. * * @since 1.4.0 */ public function maybe_create_table(): void { if ( $this->table_exists() ) { return; } $this->create_table(); } /** * Returns the full table name with prefix. * * @since 1.4.0 * * @return string The prefixed table name. */ public function get_table_name(): string { global $wpdb; return $wpdb->prefix . self::TABLE_NAME; } /** * Checks whether the embeddings table exists. * * @since 1.4.0 * * @return bool True when the table exists. */ public function table_exists(): bool { global $wpdb; $table_name = $this->get_table_name(); $existing_table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) ); return $existing_table === $table_name; } /** * Drops the table and forgets the schema version. * * @since 1.4.0 */ public function drop_table(): void { global $wpdb; $table_name = $this->get_table_name(); $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared delete_option( self::SCHEMA_VERSION_OPTION ); } /** * Brings an existing table's index set up to the current version. * * @since 1.5.0 */ private function migrate_existing_table(): void { global $wpdb; $table = $this->get_table_name(); $indexes = $this->get_index_names(); if ( ! isset( $indexes['idx_model_coverage'] ) ) { $wpdb->query( "ALTER TABLE `{$table}` ADD KEY idx_model_coverage (provider, model, object_type, object_subtype, chunk_index, object_id)" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $indexes = $this->get_index_names(); } // Keep the old indexes until the coverage index exists. if ( ! isset( $indexes['idx_model_coverage'] ) ) { return; } foreach ( self::REMOVED_INDEXES as $index ) { if ( ! isset( $indexes[ $index ] ) ) { continue; } $wpdb->query( "ALTER TABLE `{$table}` DROP INDEX `{$index}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } } /** * Checks whether the table has exactly the current version's managed indexes. * * @since 1.5.0 * * @return bool True when the coverage index exists and no removed index remains. */ private function has_current_indexes(): bool { $indexes = $this->get_index_names(); if ( ! isset( $indexes['idx_model_coverage'] ) ) { return false; } foreach ( self::REMOVED_INDEXES as $index ) { if ( isset( $indexes[ $index ] ) ) { return false; } } return true; } /** * Creates the embeddings table. * * One row per `(object_type, object_id, provider, model, chunk_index)` — the unique key. Every * other column is an attribute of that row and is refreshed when it is re-indexed, so * re-embedding an object replaces its vector rather than accumulating a second row. * * `embedding` holds `dimensions * 4` bytes of little-endian float32 values. `embedding_coarse` * holds a binary quantization code, one bit per component, for the first pass of a two-phase * similarity search. * * `idx_model_coverage` serves coverage counts and per-model scans; the unique key serves * per-object lookups, including the sync layer's batch hash probe. * * @since 1.4.0 * @since 1.5.0 Replaced three single-purpose indexes with idx_model_coverage. */ private function create_table(): void { global $wpdb; $table_name = $this->get_table_name(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, object_type VARCHAR(32) NOT NULL, object_id BIGINT UNSIGNED NOT NULL, chunk_index INT UNSIGNED NOT NULL DEFAULT 0, provider VARCHAR(64) NOT NULL, model VARCHAR(128) NOT NULL, object_subtype VARCHAR(32) NOT NULL DEFAULT '', dimensions INT UNSIGNED NOT NULL, embedding MEDIUMBLOB NOT NULL, embedding_norm DOUBLE NOT NULL, embedding_coarse VARBINARY(512) NULL, content_hash VARCHAR(64) NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uniq_object_model_chunk (object_type, object_id, provider, model, chunk_index), KEY idx_model_coverage (provider, model, object_type, object_subtype, chunk_index, object_id) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange }