name: berlindb description: >- Use when implementing, reviewing, debugging, or migrating WordPress custom database tables that use berlindb/core 3.x. Covers Schema, Table, Row, Query, Connection adapters, parser/operator query vars, table upgrades, common save failures, and release-safe verification commands.
BerlinDB
Use this skill for BerlinDB 3.x work in WordPress plugins and libraries.
Contributing to BerlinDB core itself (editing
src/,tests/, configs)? This skill is consumer-facing — it covers using the library. The contributor workflow, coding-style rules, and verification gate live in the repository'sCLAUDE.md. Read that first when working on the core.
First Moves
- Check the installed BerlinDB version and current branch before editing.
- Prefer local source, tests, README, and CHANGELOG over memory.
- Use current namespaces:
BerlinDB\Database\Kern\SchemaBerlinDB\Database\Kern\TableBerlinDB\Database\Kern\QueryBerlinDB\Database\Kern\RowBerlinDB\Database\Interfaces\ConnectionBerlinDB\Database\Adapters\WpdbBerlinDB\Database\Adapters\NullConnection
- Do not use old 2.x paths such as
BerlinDB\Database\Schema,BerlinDB\Database\Table,BerlinDB\Database\Query, orBerlinDB\Database\Rowunless the project explicitly aliases them. - Do not invent APIs. If unsure, search
src/andtests/.
Reference Map
Read only the reference needed for the task:
references/schema-table.md: schemas, columns, indexes, tables, installs, upgrades, table versions, and nullability.references/query-row.md: query classes, row shapes, CRUD return values, filters,__in/__not_in, JSON, casts, and the three-cache model (query/by-id/secondary) withlast_changedsalt invalidation.references/debugging.md: silent save failures, table upgrade issues, wrong primary key usage, malformed query vars, and logging.references/migration-2-to-3.md: updating older BerlinDB 2.x patterns to BerlinDB 3.x.references/verification.md: local checks, CI expectations, package/archive checks, and pre-release workflow.references/extending.md: construction lifecycle hooks (which to override and which to leave alone), config args + strict mode, validating relationship declarations, authoring presets (the Meta recipe +MetaStorecontract), query-var normalization (early all-vars vs later var-local), and the custom-parser API.
Canonical Object Model
A typical integration defines:
- a
Schemasubclass with$columnsand$indexes - a
Tablesubclass with$name,$version, and$schema - a
Rowsubclass with public properties for returned data - a
Querysubclass with$table_schema,$item_shape,$table_name, names, cache group, and optional prefix/alias values
In BerlinDB 3.x, Table and Query instantiate Schema objects from class names
or accept Schema instances. Prefer ::class constants for schema and row shape.
Sanitize vs validate (when overriding): sanitize_* methods (Sanitizer
trait) make a value structurally/SQL-safe — identifiers and config args — and may
reject it. validate_* methods (Column) conform a stored value to the column's
declared type via its $validate callback. Override the one matching your
concern, and follow the same split when naming your own helpers.
Relationships (3.1.0, #193)
Declare a relationship in a column's relationships array in the Schema. Each
entry needs query (FQCN of the remote Query class), column (the column on
the remote side), and type (belongs_to | has_many); name (the accessor)
is optional, derived from the local column otherwise.
Which side declares it, and what column means, differs by type:
belongs_to— declare on the local column that holds the foreign key (the owning / "many" side).columnis the remote key it points at (usually'id').
// On 'order_id' — this row points at one Order.
'relationships' => array(
array(
'query' => \EDD\Database\Queries\Order::class,
'column' => 'id', // remote key referenced
'type' => 'belongs_to',
'name' => 'order', // optional accessor
),
),
has_many— declare on the local key the children reference back (the "one" / parent side, usually'id').columnis the remote foreign-key column pointing here.
// On 'id' — many Order_Items point back at this Order.
'relationships' => array(
array(
'query' => \EDD\Database\Queries\Order_Item::class,
'column' => 'order_id', // remote FK pointing here
'type' => 'has_many',
'name' => 'items', // optional accessor
),
),
- Prime caches with
with(quiet by default — pass accessor names to warm):$q->query( array( 'status' => 'active', 'with' => array( 'order' ) ) ); - Resolve related rows with
get_related()(on theQuery, not theRow):$order = $q->get_related( $item, 'order' );—belongs_toreturns aRowornull;has_manyreturns an array ofRows (the FULL child set — pagination is a directquery(), not a relationship accessor). - Filter rows by a relationship with the
relationquery var; two strategies:'in'(default) — resolves a subquery into a{fk}__infilter:'relation' => array( 'name' => 'order', 'where' => array( 'status' => 'complete' ), 'strategy' => 'in' )'join'— a real JOIN/EXISTS, supporting INNER (default) or'join' => 'left','exists' => false(anti-join), operator conditions (array( 'compare' => '>', 'value' => 100 )), and nested AND/ORwheregroups:'relation' => array( 'name' => 'order', 'where' => array( 'status' => 'complete' ), 'strategy' => 'join' )
- Type a comparison with an opt-in
caston awherecondition — never applied by default (a SQLCASTdefeats index use):'cast' => 'SIGNED'— explicit target (SIGNED/UNSIGNED/DECIMAL/DATE/DATETIME/TIME/CHAR); useful when comparing a value stored as text numerically:array( 'compare' => '>', 'value' => 100, 'cast' => 'SIGNED' ).'cast' => true— derive the target from the remote column's own type.- A present-but-invalid
cast(e.g. a typo) fails closed (no rows), not a silent lexical compare.
- Compare to another column with an opt-in operand in place of
value:array( 'compare' => '>', 'value' => array( 'operand' => 'column', 'name' => 'min_total' ) )compares two columns (total > min_total). Works on the scalar comparison operators (=/!=/</<=/>/>=) in relationshipwhereandcompare_queryclauses; an unknown column or an unsupported operator fails closed. The operand may also be an allow-listed SQL function wrapping recursive arguments —array( 'operand' => 'func', 'name' => 'ABS', 'args' => array( array( 'operand' => 'column', 'name' => 'balance' ) ) )for... = ABS(balance). Only listed functions (LOWER/UPPER/LENGTH/ABS/DATE/YEAR/MONTH/DAYOFMONTH/DAYOFYEAR/DAYOFWEEK/HOUR/MINUTE/SECOND) with a matching arity are allowed; no raw SQL. A column argument is type-checked against the function (YEAR()wants a date column,ABS()a numeric one) and fails closed on a mismatch. The same operand spec works on the left side via the clausekey(position selects the side) —array( 'key' => array( 'operand' => 'func', 'name' => 'LOWER', 'args' => array( array( 'operand' => 'column', 'name' => 'name' ) ) ), 'value' => 'jane' )forLOWER(name) = 'jane'; a bare scalar on the other side is prepared with the function's return type. An operandkeyalso works with a bare value onIN/BETWEEN/LIKE/IS NULL(e.g.'key' => array( 'operand' => 'func', 'name' => 'YEAR', 'args' => array( array( 'operand' => 'column', 'name' => 'created' ) ) ), 'compare' => 'IN', 'value' => array( 2023, 2024 )). Comparing two operands (a structured value) stays limited to the scalar operators (=/!=/</<=/>/>=).
Notes:
- Validate your declarations.
$schema->get_validation_errors()catches the local side (own shape, unknown local column, duplicate accessor, unsupported composite, a named-but-missing remote query class);$query->get_relationship_errors()catches the remote side on demand (the class exists but isn't aQuery, an unknown remote column) — call it from your tests or dev tooling. Malformed shorthand declarations are dropped (fail-closed) and logged with a stable code (e.g.relationship_invalid_type). - Runtime relationship features (
get_related, priming,relationfiltering) are single-column only for now — one local key referencing one remote key. - Relationship filters fail closed: a misconfigured or empty-matching filter returns no rows, never all rows.
- Enforced foreign-key metadata (
enforce,on_delete,on_update,constraint) is declarable but not yet emitted as DDL — relationships are enforced at the application layer (WordPress avoids real foreign keys).
Custom Meta Tables (3.1.0, #204)
Use the Meta preset when a table needs WordPress-style key/value metadata in a
custom sibling table instead of a core {type}meta table. The recipe is explicit:
the primary schema declares the has_many meta relationship, and thin query/table
stubs opt into the generated meta table shape.
use BerlinDB\Database\Kern\Query;
use BerlinDB\Database\Kern\Schema;
use BerlinDB\Database\Kern\Table;
use BerlinDB\Database\Presets\Meta\Query as MetaQuery;
use BerlinDB\Database\Presets\Meta\Table as MetaTable;
class Order_Schema extends Schema {
public $columns = array(
array(
'name' => 'id',
'type' => 'bigint',
'length' => '20',
'unsigned' => true,
'primary' => true,
'extra' => 'auto_increment',
'relationships' => array(
array(
'query' => Order_Meta_Query::class,
'column' => 'order_id',
'type' => 'has_many',
'name' => 'meta',
),
),
),
);
public $indexes = array(
array(
'type' => 'primary',
'columns' => array( 'id' ),
),
);
}
class Order_Query extends Query {
protected $prefix = 'acme';
protected $table_name = 'orders';
protected $table_schema = Order_Schema::class;
protected $item_name = 'order';
protected $cache_group = 'orders';
}
class Order_Table extends Table {
protected $prefix = 'acme';
protected $name = 'orders';
protected $version = '1.0.0';
protected $schema = Order_Schema::class;
}
class Order_Meta_Query extends MetaQuery {
protected $primary_query_class = Order_Query::class;
}
class Order_Meta_Table extends MetaTable {
protected $meta_query_class = Order_Meta_Query::class;
}
Instantiate/install the primary table and meta table alongside each other. The
meta query derives its table name (order_meta), foreign key (order_id), EAV
columns (meta_id, meta_key, meta_value), and belongs_to order
relationship from the primary.
Presets\Meta\Query implements Interfaces\MetaStore. A primary query's
protected add_item_meta(), get_item_meta(), update_item_meta(),
delete_item_meta(), and delete-item purge path route to the custom store when
the relationship named meta resolves to a MetaStore; otherwise they fall back
to the legacy WordPress metadata API. Expose those protected helpers from your
own Query subclass if your plugin needs public item-meta methods.
delete_item_meta( $id, $key, $value, $delete_all ) mirrors delete_metadata():
with $delete_all = true the item ID is ignored and the key is purged across
every object (a fleet-wide cleanup), on both the store and legacy paths. A
meta key is still required.
Bulk meta works too: extra non-column keys passed to add_item() /
update_item() save through the store (non-empty values update, empty values
delete), and delete_item() purges the item's meta. When a store is declared,
the WordPress register_meta() key gate is intentionally skipped — the meta
relationship IS the registration.
meta_query / meta_key / meta_value work too: for a store-backed object they
are translated into relationship EXISTS filters against the sibling table (so the
existing WordPress-shaped query surface keeps working), with compare, type
casts, and relation AND/OR all honored. A negative compare_key (e.g. NOT LIKE
on the key) becomes a NOT EXISTS over the key — "the object has no meta row whose
key matches" — except when paired with a value, which still fails closed.
WordPress-core objects keep the bespoke meta parser unchanged.
You can also order by a meta value: orderby => 'meta_value' (or 'meta_value_num'
for numeric) sorts by the simple/first clause's value, and a named meta_query
clause key — meta_query => array( 'price' => array( 'key' => 'price' ) ) with
orderby => 'price' — sorts by that clause. Store-backed objects order via a
correlated subquery (the oldest meta_id wins a multi-valued key). The array
orderby shape works for both, so several meta keys (and real columns) can be mixed
in one orderby. Named clauses both filter and sort, exactly like positional ones.
Current limitations:
- A negative
compare_keycombined with a value fails closed for store-backed objects (key absence is object-level, a value match is row-level — one EXISTS clause can't express both). A negativecompare_keyon its own translates. - Runtime relationship features remain single-column only, so Meta preset primaries should use a single primary key column.
- A string/UUID primary key is supported end-to-end (3.1.0):
add_item()with a supplied key returns that key,get_item()/update_item()/delete_item()and query result-shaping address rows by it, and the generated meta foreign key mirrors the primary key's storage shape so store-backed item meta keys off the string/UUID too. (add_item()therefore returnsint|string.) The legacy WordPress metadata fallback — for a primary with no meta store — still requires an integer ID, since{type}metatables are int-keyed.
High-Risk Gotchas
- Nullable columns use
'allow_null' => true, not'null' => true. - PHP arrays are not automatically JSON-encoded before writes.
- JSON strings are not automatically decoded after reads unless a cast or Row constructor handles them.
add_item()returns the new item ID on success — the auto-increment value (int), or the supplied string/UUID primary key — orfalseon failure.update_item()returnstruewhen a table-column update is written — or (3.1.0) when a meta-only update saves bulk meta successfully — but returnsfalsewhen nothing needs saving after diffing and capability reduction (including a meta-only update to an identical value).delete_item()returnstrueorfalse.delete_items()(3.1.0) deletes a set: a single ID, a list of IDs, or a query-var filter (same vocabulary asquery(), e.g.delete_items( array( 'status__in' => array( 'spam' ) ) )). It resolves the matching IDs and loopsdelete_item()(so hooks/cache/meta cleanup all fire), and returns the number deleted orfalse. An empty input or a filter with noWHEREdeletes nothing — it never means "delete everything".update_items( $target, $data )(3.1.0) is the write sibling: it writes$datato a set named the same three ways (single ID / list of IDs / query-var filter), loopingupdate_item(). Returns the number updated orfalse. Empty$data, an empty input, or a filter with noWHEREupdates nothing.add_items( $rows )(3.1.0) is the create sibling: it inserts a list of data arrays, one new item each, loopingadd_item(). It takes no set selector (the rows do not exist yet), and returns the new IDs in input order — each slot the new ID, orfalsewhere that insert failed — rather than a count. An empty input inserts nothing and returnsarray().update_item()anddelete_item()expect the primary-key value, not a slug or other business key.numbercan intentionally limit queries that also use__inor__not_in.- Table
$versionvalues are schema/database versions for that table, not the BerlinDB package version. Must be a string —strict_types=1andversion_compare()both require it. no_found_rowsdefaults totrue. In that mode,get_found_items()returns only the current-page item count, not the total matching rows — not useful for pagination. Pass'no_found_rows' => falseto run the separateCOUNT(*)query and get the true total fromget_found_items().- In this repository's database tests, call
wp_set_current_user( 1 )before CRUD writes.Query::reduce_item()runscurrent_user_can()checks before saving column data, and the default test fixtures otherwise strip columns and make inserts/updates look broken. - For multisite global tables (shared across all sites), set
$global = trueon the Table subclass. Per-site tables omit this property.
Verification Defaults
For BerlinDB core changes, run:
composer validate --strict --no-check-publish
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/phpcs
bin/run-tests.sh -p 8.1 -w 6.7 -- --group default
bin/run-tests.sh -p 8.2 -w 6.7 -- --group default
For plugin integrations, run the plugin's own PHPUnit/static-analysis suite and at least one integration path that creates/upgrades the table and performs insert, update, query, and delete operations.