From 61bc6c15e3e492ac12a1b1e60813a1676695c282 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Fri, 25 Sep 2026 01:47:08 -0500 Subject: [PATCH] Meta: derive cleanup columns from the Schema. Treat MetaStore as the ownership boundary for item-meta cleanup. BerlinDB-owned stores now resolve their row and object columns from the registered Schema, while WordPress-owned metadata continues through the metadata API so hooks and cache invalidation retain their expected behavior. Fixes #262. --- CHANGELOG.md | 9 ++ docs/upgrading-to-3.1.md | 7 + src/Database/Presets/Meta/Query.php | 152 ++++++++++++++++-- src/Database/Traits/Query/Meta.php | 83 ++-------- tests/Database/Kern/Query/MetaTypeTest.php | 42 +++-- .../Kern/Query/UuidPrimaryKeyTest.php | 61 ++++++- 6 files changed, 252 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef426443..984d8c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Notable changes to BerlinDB are documented here. ## 3.1.0 - Unreleased +- Makes `MetaStore` the ownership boundary for item-meta cleanup (#262). A + BerlinDB-owned Meta preset now derives its meta-row primary column and owning-object + column from its registered Schema, so customized names are deleted through the + remote Query's normal item engine without `meta_id`, `umeta_id`, or `{type}_id` + guesses. A non-`MetaStore` relationship that models a WordPress-owned meta table + remains on WordPress's metadata API: cleanup runs once per key, preserving its + short-circuit filters, delete actions, and object-cache invalidation. This replaces + the previous per-meta-ID hook cadence for that modeled-WordPress-table path. + - Preserves selected released subclass signatures for schema accessors, Query helpers, and operator rendering. New schema filtering uses `get_filtered_items()`, `get_filtered_columns()`, and `get_filtered_indexes()`; each filters the result of diff --git a/docs/upgrading-to-3.1.md b/docs/upgrading-to-3.1.md index 7b96844f..fc69f06f 100644 --- a/docs/upgrading-to-3.1.md +++ b/docs/upgrading-to-3.1.md @@ -84,6 +84,13 @@ The older datetime, decimal, and UUID Column validators remain public. `get_item_meta()` retrieves all item meta. The documented `delete_item_meta( $id, $key, '', true )` call now deletes that key across every object, so audit calls that pass `true` for the final argument. +- Item deletion treats a related `MetaStore` as BerlinDB-owned and resolves its + meta-row and owning-object columns from the store's registered Schema. A plain + `Query` relationship that only models a WordPress meta table remains + WordPress-owned; cleanup uses the WordPress metadata API once per key, including + its short-circuit filters, delete actions, and object-cache invalidation. Code + that observed the earlier per-meta-ID hook cadence should expect one hook call + per key instead. - Invalid relationship declarations are dropped with a warning. Use `Schema::get_validation_errors()` and `Query::get_relationship_errors()` to inspect local and remote declarations during an upgrade. diff --git a/src/Database/Presets/Meta/Query.php b/src/Database/Presets/Meta/Query.php index 87ee0ea4..2204cd35 100644 --- a/src/Database/Presets/Meta/Query.php +++ b/src/Database/Presets/Meta/Query.php @@ -79,14 +79,36 @@ class Query extends KernQuery implements MetaStore { /** * Name of the foreign-key column pointing at the primary (e.g. 'order_id'). * - * Derived during configure_from_primary(); the MetaStore methods address - * rows through it. + * Derived from the resolved Schema by configure_columns_from_schema(); the + * MetaStore methods address rows through it. * * @since 3.1.0 * @var string */ private $object_id_column_name = ''; + /** + * Name of this table's primary meta-row column. + * + * Derived from the registered Schema after it is resolved; MetaStore methods + * address individual rows through it instead of assuming `meta_id`. + * + * @since 3.1.0 + * @var string + */ + private $meta_id_column_name = ''; + + /** + * Name of the primary object's single key column. + * + * Captured from the primary Query's registered Schema and used to select the + * exact belongs_to relationship that owns this meta table. + * + * @since 3.1.0 + * @var string + */ + private $primary_id_column_name = ''; + /** * Derive identity and schema from the primary before normal setup. * @@ -96,6 +118,8 @@ protected function init(): void { $this->configure_from_primary(); parent::init(); + + $this->configure_columns_from_schema(); } /** @@ -174,18 +198,94 @@ private function configure_from_primary(): void { $meta_table_name = "{$object_name}_meta"; // Late static binding, so a stub may override build_schema() to customize. - $this->prefix = $primary_query->get_prefix(); - $this->table_name = $meta_table_name; - $this->item_name = $meta_table_name; - $this->item_name_plural = $meta_table_name; - $this->cache_group = $meta_table_name; - $this->object_id_column_name = self::sanitize_object_name( $object_name ) . '_id'; - $this->table_schema = static::build_schema( $primary_key_column, $object_name, $this->primary_query_class ); - - // Mark success; Meta-specific paths bail when this never happened. + $this->prefix = $primary_query->get_prefix(); + $this->table_name = $meta_table_name; + $this->item_name = $meta_table_name; + $this->item_name_plural = $meta_table_name; + $this->cache_group = $meta_table_name; + $this->table_schema = static::build_schema( $primary_key_column, $object_name, $this->primary_query_class ); + $this->primary_id_column_name = $primary_key_column->name; + + // Mark primary configuration success; schema-column configuration verifies it. $this->configured_from_primary = true; } + /** + * Resolve the meta row and owning-object columns from the registered Schema. + * + * The primary key identifies one meta row. The owning-object column is the + * single local column of the Schema's belongs_to relationship back to the + * configured primary Query. A customized build_schema() therefore remains the + * authority for both names. + * + * @since 3.1.0 + */ + private function configure_columns_from_schema(): void { + + // Bail when primary configuration already failed. + if ( ! $this->configured_from_primary ) { + return; + } + + // Require exactly one real, sortable primary column from the resolved Schema. + $primary_names = $this->get_primary_column_names(); + $primary = ( 1 === count( $primary_names ) ) + ? $primary_names[0] + : ''; + $primary_column = $this->get_column_by( array( 'name' => $primary ) ); + + if ( ! ( $primary_column instanceof Column ) || ! $primary_column->sortable ) { + $this->configured_from_primary = false; + $this->log( 'error', 'meta_schema_primary_missing', 'Meta query schema has no single sortable primary meta-row column; not configured.' ); + + return; + } + + // The Meta preset's storage contract requires both EAV value columns. + foreach ( array( 'meta_key', 'meta_value' ) as $column_name ) { + if ( ! ( $this->get_column_by( array( 'name' => $column_name ) ) instanceof Column ) ) { + $this->configured_from_primary = false; + $this->log( + 'error', + 'meta_schema_column_missing', + 'Meta query schema is missing a required EAV column; not configured.', + array( 'column' => $column_name ) + ); + + return; + } + } + + $matches = array(); + + // Find the Schema-owned foreign key back to the configured primary Query. + foreach ( $this->get_belongs_to_relationships() as $relationship ) { + if ( + ( 0 === strcasecmp( ltrim( $this->primary_query_class, '\\' ), ltrim( $relationship->get_query_class(), '\\' ) ) ) + && ( 1 === count( $relationship->columns ) ) + && ( 1 === count( $relationship->references ) ) + && ( $this->primary_id_column_name === $relationship->references[0] ) + ) { + $matches[] = $relationship->columns[0]; + } + } + + // Exactly one Schema-owned object-ID column is required by this preset. + $object_id = ( 1 === count( $matches ) ) + ? $matches[0] + : ''; + + if ( ! ( $this->get_column_by( array( 'name' => $object_id ) ) instanceof Column ) ) { + $this->configured_from_primary = false; + $this->log( 'error', 'meta_schema_owner_missing', 'Meta query schema has no unambiguous owning-object relationship; not configured.' ); + + return; + } + + $this->meta_id_column_name = $primary; + $this->object_id_column_name = $object_id; + } + /** * Return whether this meta query successfully configured from its primary. * @@ -366,7 +466,9 @@ public function update_meta( int|string $object_id, string $meta_key, mixed $met */ $retval = false; foreach ( $rows as $row ) { - if ( $this->update_item( $row->meta_id, array( 'meta_value' => $serialized ) ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + $meta_id = $this->get_meta_row_id( $row ); + + if ( ( false !== $meta_id ) && $this->update_item( $meta_id, array( 'meta_value' => $serialized ) ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value $retval = true; } } @@ -432,7 +534,9 @@ public function delete_meta( int|string $object_id, string $meta_key, mixed $met // Delete each matching entry through the normal item engine. $retval = false; foreach ( $rows as $row ) { - if ( $this->delete_item( $row->meta_id ) ) { + $meta_id = $this->get_meta_row_id( $row ); + + if ( ( false !== $meta_id ) && $this->delete_item( $meta_id ) ) { $retval = true; } } @@ -466,7 +570,9 @@ public function delete_all_meta( int|string $object_id ): bool { // Delete each entry through the normal item engine. $retval = false; foreach ( $rows as $row ) { - if ( $this->delete_item( $row->meta_id ) ) { + $meta_id = $this->get_meta_row_id( $row ); + + if ( ( false !== $meta_id ) && $this->delete_item( $meta_id ) ) { $retval = true; } } @@ -474,6 +580,22 @@ public function delete_all_meta( int|string $object_id ): bool { return $retval; } + /** + * Get one meta row's Schema-owned primary value. + * + * @since 3.1.0 + * + * @param Row $row Meta row. + * @return int|string|false The row ID, or false when unavailable. + */ + private function get_meta_row_id( Row $row ): int|string|false { + $value = $row->{$this->meta_id_column_name}; + + return ( ( is_int( $value ) && ( 0 < $value ) ) || ( is_string( $value ) && ( '' !== $value ) ) ) + ? $value + : false; + } + /** * Fetch meta rows for an object and/or key, ordered oldest-first. * @@ -493,7 +615,7 @@ private function get_meta_rows( int|string|null $object_id, string $meta_key ): // Unlimited, oldest-first (insertion order, like the WP meta API). $args = array( 'number' => 0, - 'orderby' => 'meta_id', + 'orderby' => $this->meta_id_column_name, 'order' => 'asc', ); diff --git a/src/Database/Traits/Query/Meta.php b/src/Database/Traits/Query/Meta.php index 915bbee8..3e9301e4 100644 --- a/src/Database/Traits/Query/Meta.php +++ b/src/Database/Traits/Query/Meta.php @@ -445,85 +445,28 @@ private function delete_all_item_meta( $item_id = 0 ): void { return; } - // Get the meta table name. - $table = $this->get_meta_table_name(); - - // Bail if no meta table exists. - if ( empty( $table ) ) { + // Bail when WordPress has no registered metadata table for this type. + if ( false === $this->get_meta_table_name() ) { return; } - // Get the primary column name. - $primary = $this->get_primary_column_name(); - - $meta_type = $this->get_meta_type(); - $item_id_column = ''; - $meta_id_column = ''; - $item_id_pattern = $this->get_column_field( array( 'name' => $primary ), 'pattern', '%s' ); - - /* - * A declared meta relationship supplies the object-ID column and the remote - * schema supplies its primary key. Only use it when it describes the same - * WordPress meta table that delete_metadata_by_mid() will delete from. - */ - $relationship = $this->get_relationship( 'meta' ); - if ( - ( $relationship instanceof Relationship ) - && ( 'has_many' === $relationship->type ) - && ( array( $primary ) === $relationship->columns ) - && ( 1 === count( $relationship->references ) ) - ) { - $remote = $this->resolve_remote_query( $relationship ); - - if ( ( null !== $remote ) && ( $table === $remote->get_table_name() ) ) { - $reference = $relationship->references[0]; - $remote_primary = $remote->get_primary_column_name(); - - if ( - ( false !== $remote->get_column_by( array( 'name' => $reference ) ) ) - && ( false !== $remote->get_column_by( array( 'name' => $remote_primary ) ) ) - ) { - $item_id_column = $reference; - $meta_id_column = $remote_primary; - } - } - } - /* - * Without a registered meta schema, leave column selection to WordPress. - * Its metadata API knows the table's object and row ID columns, including - * usermeta's umeta_id, so no SQL column names need to be guessed here. + * A non-store path belongs to WordPress, even when a relationship models its + * table for querying. Leave table ownership, column selection, hooks, and cache + * invalidation to the metadata API; a relationship name alone does not transfer + * those responsibilities to BerlinDB. */ - if ( ( '' === $item_id_column ) || ( '' === $meta_id_column ) ) { - // The legacy WordPress metadata API accepts integer object IDs only. - if ( ! is_int( $item_id ) ) { - return; - } - - $meta = get_metadata( $meta_type, $item_id ); - - if ( is_array( $meta ) ) { - foreach ( array_keys( $meta ) as $key ) { - delete_metadata( $meta_type, $item_id, $key ); - } - } - + if ( ! is_int( $item_id ) ) { return; } - // Get meta IDs. - $query = "SELECT {$meta_id_column} FROM {$table} WHERE {$item_id_column} = {$item_id_pattern}"; - $prepared = $this->db()->prepare( $query, $item_id ); - $meta_ids = $this->db()->get_col( $prepared ); - - // Bail if no meta IDs to delete. - if ( empty( $meta_ids ) ) { - return; - } + $meta_type = $this->get_meta_type(); + $meta = get_metadata( $meta_type, $item_id ); - // Delete all meta data for this item ID. - foreach ( $meta_ids as $mid ) { - delete_metadata_by_mid( $meta_type, $mid ); + if ( is_array( $meta ) ) { + foreach ( array_keys( $meta ) as $key ) { + delete_metadata( $meta_type, $item_id, $key ); + } } } diff --git a/tests/Database/Kern/Query/MetaTypeTest.php b/tests/Database/Kern/Query/MetaTypeTest.php index 7dd91da1..9a6d2284 100644 --- a/tests/Database/Kern/Query/MetaTypeTest.php +++ b/tests/Database/Kern/Query/MetaTypeTest.php @@ -224,32 +224,46 @@ public function test_comment_meta_cleanup_uses_wordpress_object_id_column(): voi $this->assertSame( '', get_metadata( 'comment', $id, 'berlindb_cleanup_probe', true ) ); } - /** User cleanup gets umeta_id from the declared remote schema. */ - public function test_user_meta_cleanup_selects_umeta_id(): void { - global $wpdb; - + /** A modeled WordPress table still uses per-key WordPress hooks and cache cleanup. */ + public function test_user_meta_cleanup_preserves_wordpress_ownership(): void { $id = 987654322; - add_metadata( 'user', $id, 'berlindb_cleanup_probe', 'present' ); + add_metadata( 'user', $id, 'berlindb_cleanup_probe', 'first' ); + add_metadata( 'user', $id, 'berlindb_cleanup_probe', 'second' ); + add_metadata( 'user', $id, 'berlindb_cleanup_other', 'third' ); - $queries = array(); - $capture = static function ( $sql ) use ( &$queries ) { - $queries[] = $sql; - return $sql; + // Prime WordPress's object cache before cleanup. + $this->assertCount( 2, get_metadata( 'user', $id, 'berlindb_cleanup_probe' ) ); + + $before = array(); + $after = array(); + + $capture_before = static function ( $meta_ids, $object_id, $meta_key ) use ( &$before ) { + $before[] = array( $meta_ids, $object_id, $meta_key ); + }; + $capture_after = static function ( $meta_ids, $object_id, $meta_key ) use ( &$after ) { + $after[] = array( $meta_ids, $object_id, $meta_key ); }; - add_filter( 'query', $capture ); + add_action( 'delete_user_meta', $capture_before, 10, 3 ); + add_action( 'deleted_user_meta', $capture_after, 10, 3 ); try { ( new ReflectionMethod( MtqUserMetaQuery::class, 'delete_all_item_meta' ) ) ->invoke( new MtqUserMetaQuery(), $id ); } finally { - remove_filter( 'query', $capture ); + remove_action( 'delete_user_meta', $capture_before, 10 ); + remove_action( 'deleted_user_meta', $capture_after, 10 ); } - $this->assertStringContainsString( "SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id =", implode( "\n", $queries ) ); - $this->assertSame( '', get_metadata( 'user', $id, 'berlindb_cleanup_probe', true ) ); + // One metadata-API call per key; duplicate rows travel together in the hook. + $this->assertCount( 2, $before ); + $this->assertCount( 2, $after ); + $this->assertEqualsCanonicalizing( array( 'berlindb_cleanup_probe', 'berlindb_cleanup_other' ), array_column( $before, 2 ) ); + $this->assertCount( 2, $before[0][0] ); + $this->assertSame( array( $id, $id ), array_column( $before, 1 ) ); + $this->assertSame( array(), get_metadata( 'user', $id ) ); } - /** User cleanup without a meta relationship delegates column selection to WordPress. */ + /** A missing relationship follows the same WordPress-owned cleanup path. */ public function test_user_meta_cleanup_without_meta_relationship(): void { $id = 987654323; add_metadata( 'user', $id, 'berlindb_cleanup_probe', 'first' ); diff --git a/tests/Database/Kern/Query/UuidPrimaryKeyTest.php b/tests/Database/Kern/Query/UuidPrimaryKeyTest.php index e2037564..99cd2576 100644 --- a/tests/Database/Kern/Query/UuidPrimaryKeyTest.php +++ b/tests/Database/Kern/Query/UuidPrimaryKeyTest.php @@ -16,6 +16,7 @@ namespace BerlinDB\Tests; +use BerlinDB\Database\Kern\Column; use BerlinDB\Database\Kern\Query; use BerlinDB\Database\Kern\Schema; use BerlinDB\Database\Kern\Table; @@ -34,7 +35,7 @@ class UuidThingSchema extends Schema { 'relationships' => array( array( 'query' => UuidThingMetaQuery::class, - 'column' => 'thing_id', + 'column' => 'owner_uuid', 'type' => 'has_many', 'name' => 'meta', ), @@ -82,7 +83,52 @@ class UuidThingTable extends Table { /** Meta Query + Table stubs (the FK mirrors the varchar(36) primary). */ class UuidThingMetaQuery extends MetaQuery { - protected $primary_query_class = UuidThingQuery::class; + protected $primary_query_class = '\\BerlinDB\\Tests\\UuidThingQuery'; + + /** Build a valid meta Schema with deliberately nonstandard ownership columns. */ + public static function build_schema( Column $primary_key_column, string $object_name, string $primary_query_class ): Schema { + return new Schema( + array( + 'columns' => array( + array( + 'name' => 'record_id', + 'id' => true, + ), + array( + 'name' => 'owner_uuid', + 'type' => $primary_key_column->type, + 'length' => $primary_key_column->length, + 'pattern' => $primary_key_column->pattern, + 'cast' => $primary_key_column->cast, + 'relationships' => array( + array( + 'query' => ltrim( $primary_query_class, '\\' ), + 'column' => $primary_key_column->name, + 'type' => 'belongs_to', + 'name' => $object_name, + ), + ), + ), + array( 'wp_meta_key' => true ), + array( 'wp_meta_value' => true ), + ), + 'indexes' => array( + array( + 'type' => 'primary', + 'columns' => array( 'record_id' ), + ), + array( + 'name' => 'owner_uuid', + 'columns' => array( 'owner_uuid' ), + ), + array( + 'name' => 'meta_key', + 'columns' => array( 'meta_key' ), + ), + ), + ) + ); + } } class UuidThingMetaTable extends MetaTable { protected $meta_query_class = UuidThingMetaQuery::class; @@ -247,10 +293,19 @@ public function test_store_backed_meta_with_uuid_primary_key() { // Routes to the store with a UUID object_id (previously is_int-rejected). $this->assertNotFalse( $query->expose_add_meta( $uuid, 'color', 'blue' ) ); + $this->assertNotFalse( $query->expose_add_meta( $uuid, 'size', 'large' ) ); $this->assertSame( 'blue', $query->expose_get_meta( $uuid, 'color', true ) ); - // The store addresses the row by its UUID foreign key. + // Store CRUD addresses rows through the Schema-owned primary and foreign keys. $this->assertSame( array( 'blue' ), $store->get_meta( $uuid, 'color' ) ); + $this->assertTrue( $store->update_meta( $uuid, 'color', 'red' ) ); + $this->assertSame( array( 'red' ), $store->get_meta( $uuid, 'color' ) ); + $this->assertTrue( $store->delete_meta( $uuid, 'size' ) ); + $this->assertSame( array(), $store->get_meta( $uuid, 'size' ) ); + + // Prime the read cache, then delete through the primary's MetaStore route. + $this->assertTrue( $query->delete_item( $uuid ) ); + $this->assertSame( array(), $store->get_meta( $uuid, 'color' ) ); } /**