Skip to content

Version 7.0: observers driven by the anydataset-db DatabaseExecutor - #33

Merged
byjg merged 19 commits into
masterfrom
7.0
Oct 20, 2026
Merged

byjg merged 19 commits into
masterfrom
7.0

Conversation

@byjg

@byjg byjg commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

Version 7.0 rewires the entire observer system on top of the low-level DatabaseExecutor observer from byjg/anydataset-db 7.0, and migrates off the driver query methods removed in that release.

  • ORMSubject removed: dispatch now flows through ObserverBridge, a DatabaseEventObserverInterface implementation attached one-per-DatabaseExecutor (tracked in a WeakMap — no global state, nothing to clear in tests)
  • Complete coverage: every write built by MicroOrm notifies — save(), delete(), deleteByQuery(), bulkExecute() (after commit, nothing on rollback), buildAndExecute(), and built statements executed directly on the executor
  • Same semantics for save(): deferred dispatch keeps notifying after key rehydration, so Insert observers still receive the entity with its generated PK
  • OrmSqlStatement: every builder build() returns it, stamped with the ORM event and affected table; exposed to observers via ObserverData::getStatement()
  • New ObserverEvent::SoftDelete: soft deletes now fire an event (previously silent)
  • New StatementHookInterface: before-execute hook with full SQL/params access; throwing vetoes the write
  • Raw observer passthrough: Repository::addObserver() also accepts a raw DatabaseEventObserverInterface; new removeObserver()
  • Performance: zero observers → zero cost (no singleton call per write anymore); non-matching tables exit on a single hash check; no new SELECTs

ObserverProcessorInterface implementations require no code change. Breaking changes and the migration table are documented in CHANGELOG-7.0.md.

Breaking changes

  • ORMSubject removed (including clearObservers())
  • Observer scope changed from global to per-executor: repositories on different DatabaseExecutor instances no longer cross-notify
  • More events fire than before (SoftDelete, bulk, direct executions)
  • Repository protected helpers insert()/insertWithAutoInc()/insertWithKeyGen()/update() now receive the pre-built OrmSqlStatement
  • Requires byjg/anydataset-db: ^7.0 (driver query methods removed upstream)

Test plan

  • All 5 pre-existing observer tests pass with unchanged assertions (semantic gate, including generated-id visibility on insert)
  • 10 new tests in tests/ObserverBridgeTest.php: soft delete event, direct executor writes, bulk commit/rollback, per-executor scoping, veto hook, getStatement(), raw passthrough, addDbDriverForWrite() re-attach, onError() swallowing
  • Full suite: 336 tests, 1012 assertions, green against anydataset-db 7.0.x-dev; Psalm clean

byjg added 19 commits July 12, 2026 11:56
…erver

- Remove the global ORMSubject singleton; observer dispatch now flows
  through ObserverBridge, a DatabaseEventObserverInterface implementation
  attached per DatabaseExecutor
- Query builders return OrmSqlStatement (extends SqlStatement) stamped
  with the ORM event and affected table
- All writes now notify: save(), delete(), deleteByQuery(), bulkExecute()
  (after commit), buildAndExecute() and built statements executed directly
  on the executor
- save() keeps its previous semantics via deferred dispatch: observers
  receive the entity rehydrated with generated keys
- New ObserverEvent::SoftDelete case: soft deletes now fire an event
- New StatementHookInterface: before-execute hook with veto (throwing
  aborts the write)
- Repository::addObserver() also accepts raw DatabaseEventObserverInterface;
  new Repository::removeObserver(); addDbDriverForWrite() re-attaches
  observers to the new write executor
- ObserverData::getStatement() exposes the SQL statement to observers
- Require byjg/anydataset-db ^7.0 and migrate tests/docs off the driver
  query methods removed in that release
FieldUuidAttribute exposed only primaryKey/fieldName/fieldAlias/syncWithDb and
hard-coded the UUID select/update mappers, so a binary UUID foreign key could not
register a relationship via ORM::addRelationship — unlike a plain FieldAttribute.
Add the parentTable pass-through (updateFunction/selectFunction stay fixed to the
UUID mappers). The join SQL built from the relationship is dialect-agnostic
(QueryBasic::getJoin concatenates a standard INNER JOIN ... ON), so this works
across MySQL, Postgres, Sqlite and SqlServer.

insertFunction is intentionally not exposed (the UUID conversion for writes is
handled by the update function on the value-prepare path).
getRelationship() retried incomplete relationships (parent primary key still '?')
with an inverted condition: it skipped exactly the case where the parent mapper
had since been registered, so a child mapper created before its parent kept a '?'
primary key and produced a broken join (parent.? = child.fk). Flip the guard so
the retry runs once the parent is available and the real primary key is filled in.

This matters when relationships are declared via FieldAttribute(parentTable:) and
mappers register in dependency-injection order rather than parent-first.
…ORM::getQueryInstance

joinRelated/leftJoinRelated/rightJoinRelated derive a JOIN's ON condition from a
registered parentTable relationship and connect a table to one already in the query. If
they are not directly related, the intermediate tables on the shortest relationship path
are joined automatically (tables already in the query are skipped); the query keeps its
own base table, so it composes with a repository query. A shared addRelatedJoin() helper
walks the path and delegates to join()/leftJoin()/rightJoin().

ActiveRecord::joinWith() now builds on joinRelated() (starting from the model's own table)
instead of ORM::getQueryInstance.

ORM::getQueryInstance() is removed — joinRelated/joinWith replace it, keeping the base
table, supporting INNER/LEFT/RIGHT, and auto-joining intermediates without the malformed
double-join it produced. The relationship registry (addRelationship/getRelationship/
getRelationshipData) is unchanged.

Docs (auto-discovering-relationship, comparison-with-other-orms) and CHANGELOG-7.0 updated.
…ion)

Passing a class-string to joinRelated/leftJoinRelated/rightJoinRelated/joinWith
now resolves it to its table via new ORM::getTableFromClass(), registering the
entity's mapper on demand. Registration is reflection-only (no DB connection), so
relationship auto-discovery works on a request that never touched that entity's
repository. Name each entity in a multi-hop path (like hasManyThrough's through-model)
to make the intermediate mapper known. Backward compatible: a table-name string does
not class_exists, so it is used verbatim.
Widen the PHP constraint to ">=8.3 <8.7"; the previous "<8.6" is
exclusive and excluded PHP 8.6.

Bump byjg/* dependencies to ^7.0. While 7.0 is unreleased these resolve
to 7.0.x-dev from each component's 7.0 branch, via minimum-stability dev
with prefer-stable.

Pin PHPUnit to ^12.5 and move Psalm to tools/psalm/composer.json. Psalm
enumerates supported PHP versions and no release lists 8.6, so as a
require-dev it made "composer install" fail on the 8.6 build before any
test ran. "composer psalm" bootstraps the tool and runs it.

CI: add PHP 8.6 to the matrix; the Psalm job runs on 8.5.

Housekeeping: rename phpunit.xml.dist to phpunit.xml, carry the 6.0
changelog onto this branch, and update CHANGELOG-7.0.md.
licensing.html returns 404; the page is /license/. The Dependencies
mermaid drifted from composer.json, which is now what the site's graph
is built from (collect-deps.py in add-doc).
This copy came from a template shared by eleven repositories: its Issues
link was the placeholder github.com/your-repo/issues, and nothing in it
was specific to this repository. GitHub now serves the account-wide
CONTRIBUTING.md from byjg/.github, which links the guide on
opensource.byjg.com.
- Install Psalm as psalm/phar instead of the tools/psalm project
- MapperFunctionsTest stubs DatabaseExecutor instead of mocking it without
  expectations (two PHPUnit notices)
- phpunit.xml: PHPUnit 12.5 schema and failOnPhpunitDeprecation
- Workflow: actions/checkout@v7
- README and docs: Installation and Running Tests sections, sidebar positions,
  https links
- Remove FUNDING.yml (comes from byjg/.github), IDE files and test.php
- CHANGELOG-7.0.md: in-development banner
Repository::save() replaced the primary key of a new instance: the table's
seed function generated a new one, and without a seed function the last
insert id read back after the insert took its place, so an id of 100 came
back as 1. The given key is now inserted as is; a key is only generated or
read back when it is empty.
TableUuidPKAttribute generates a version 7 UUID (time-ordered) in PHP and
FieldUuidAttribute writes UUIDs in the syntax of the repository's database:
X'...' for BINARY(16) on MySQL and SQLite, 0x... for BINARY(16) on SQL
Server, and the quoted text for a native uuid on PostgreSQL. It used to
write X'...' everywhere, which PostgreSQL and SQL Server do not accept.

New HexUuidLiteral::v7() and HexUuidLiteral::forDatabase(). The MySQL,
SQLite and PostgreSQL table attributes are deprecated aliases of
TableUuidPKAttribute; the MySQL one called uuid_to_bin(uuid()), which
failed on any other database. PostgresUuidLiteral now writes the quoted
text form instead of an unquoted \x....

The UUID repository tests run on MySQL, SQLite, PostgreSQL and SQL Server
(pdo_dblib and pdo_sqlsrv), which are added to docker-compose and CI.
The SQL Server tests called markTestSkipped() inside setUp(), so on a machine without the extension (CI has no pdo_sqlsrv) tearDown() still ran and used the executor setUp() never set: 6 errors. Each per-database test now declares #[RequiresPhpExtension], which PHPUnit checks before setUp().
- An empty primary key is left out of the INSERT instead of written as
  NULL: MySQL and SQLite generate the key anyway, but PostgreSQL (serial)
  and SQL Server (identity) reject an explicit NULL.
- created_at and updated_at (NowUtcMapper) and a soft delete (deleted_at)
  write CURRENT_TIMESTAMP. They wrote the dialect's sqlDate(), a text made
  to display a date, which PostgreSQL rejects for a timestamp column and
  SQL Server could not convert.

Tested on MySQL, SQLite, PostgreSQL and SQL Server (dblib and sqlsrv);
CURRENT_TIMESTAMP was also checked on Oracle.
A property typed with a backed enum (public ?Status $status) is saved as the enum's value, loaded back as the enum, and filters by its value in queryInstance($model). byjg/serializer 7.0 does the conversion (a1ca2ea); RepositoryBackedEnumTest runs the three paths on SQLite, and docs/getting-started-model.md shows the model.
- Paging returned the wrong rows past the first page.
- where() accepted an array that always failed with a TypeError.
@byjg
byjg merged commit 89ef79b into master Oct 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant