Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/upgrading-to-3.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 137 additions & 15 deletions src/Database/Presets/Meta/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -96,6 +118,8 @@ protected function init(): void {
$this->configure_from_primary();

parent::init();

$this->configure_columns_from_schema();
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -466,14 +570,32 @@ 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;
}
}

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.
*
Expand All @@ -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',
);

Expand Down
83 changes: 13 additions & 70 deletions src/Database/Traits/Query/Meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
}

Expand Down
Loading
Loading