omnibase's entities on Eloquent's database layer, framework-free: illuminate/database (Laravel's connection, or Capsule
outside Laravel) reading the tables omnisense/omnibridge's
Omnibridge\Schema describes into the same plain PHP entities Doctrine maps - a data mapper, never Eloquent models.
composer require omnibridge/eloquent
- Reading entities: the connection, the store, associations, the identity map, the casts, the unit of work.
- Writing entities: the unit of work - persist, remove, flush in Doctrine's order, the cascades, the lifecycle events, what is refused.
- omnibase's persistence:
EloquentPersistence, the backend and its scopes, the repositories. - The persistence of a host: chosen by what is installed and the host's option, what Symfony, Laravel and plain PHP get, the lifecycle attached from the host's services.
- omnibase's behaviours: the attributes' relay, their parts on Eloquent, the listeners' adapters, what is refused.
- The finder language: omnibase's finders compiled onto Illuminate's query builder, what is refused, the differences with omnibridge/doctrine.
- Testing: the suite, the comparisons with Doctrine (reads by id, the finder parity) in omnisense/omnibase's harness and on an application's database.
Steps F7b (the reads), F7c (the writes, then the behaviours) and F7d (the hosts) of omnisense/omnibase's plan "omnibase agnostic".
Proposer une modification docs/index.md
Reading entities
The connection
Omnibridge\Eloquent\Connections gives an Illuminate\Database\Connection, with or without Laravel:
| Where | Call |
|---|---|
| Laravel | Connections::laravel(app('db'), 'mysql') (the default connection without a name) |
| plain PHP | Connections::capsule(['driver' => 'mysql', 'host' => ..., 'database' => ...]), Connections::url('mysql://user:password@host:3306/name?charset=utf8mb4'), Connections::url('sqlite:///var/data.db') |
| beside Doctrine | Connections::pdo($entityManager->getConnection()->getNativeConnection(), $database): the same PDO, so the same transaction |
The store
use Omnibridge\Eloquent\{Casts, EntityStore, Mapping};
$store = new EntityStore($connection, new Mapping($description), Casts::utc($columnTypes));
find($class, $id): the entity, or null. A class with subclasses gives the row's own class (its discriminator). The same row read twice is the same object (IdentityMap), and a row read already costs no query.findMany($class, $ids): in the order of the identifiers, the missing ones left out; one query for the rows not read yet.reference($class, $id): a lazy ghost, its identifier set, its row read on the first other field asked (EntityNotFoundif there is none); a class with subclasses is read at once (null without a row), as Doctrine does.select($class, fn (Builder $query, EntityMap $map) => ...): the rows a constraint on Illuminate's query builder keeps, in its order. The class's tables are aliasedt0(the root's),t1...;$map->column('title')ist0.title.refresh($entity),identifier($entity),isUninitialized($entity),clear().addCondition($name, fn (EntityMap $map, Builder $query) => ...): a condition on every query while it is set (a backend's scope),removeCondition($name).
Associations
| Association | What the property holds |
|---|---|
| to-one, owning, target without subclasses | the entity read already, else a PHP 8.4 lazy ghost of the target's class (newLazyGhost), its identifier set |
| to-one, owning, target with subclasses | the entity read at once (only its row says its class) |
| one-to-one, inverse side | the entity read at once (only a row says whether there is one), or null |
| one-to-many, many-to-many (owning or inverse) | a LazyCollection (doctrine/collections' Collection), read on first use, in the declared order (orderBy), keyed by the declared field (indexBy) |
The entities are made without their constructor and their properties written raw
(ReflectionProperty::setRawValueWithoutLazyInitialization()), no hook, no setter: what Doctrine does.
The casts
Casts converts a column's value by the type's name a description gives:
- DBAL's portable types: integer, smallint, bigint (a string past
PHP_INT_MAX), boolean, float, decimal (a string), string, text, ascii_string, guid, json, simple_array (NULL as[]), datetime, datetimetz, date, time and their_immutable, binary, blob; Casts::utc(): datetime and datetimetz in UTC, utc_datetime and utc_datetime_immutable (omnibridge/doctrine'sTypes::register());->with('money', $toPhp, $toDatabase): an application's own type;- the
ColumnTypesgiven: an alias followed, an enumeration's value as its string, a set's values as a list.
A type nothing knows is refused (DomainException), never read as a raw string.
The unit of work
EloquentUnitOfWork answers omnisense/omnibridge's UnitOfWork for what reads: contains(), reference(), isNew(),
identifier(), className() (a ghost is of its own class), refresh(); and writes (Writing entities).
The store remembers what each entity held when it was read (original(), originalCollections(): what a flush
compares with) and tells its load listeners of each entity read, once the read is done (onLoad(): the unit of
work's PostLoad).
Proposer une modification docs/reading.md
omnibase's persistence
Loaded with omnisense/omnibase 5.0 (in suggest).
use Omnibridge\Eloquent\Connections;
use Omnibridge\Eloquent\Omnibase\EloquentPersistence;
$persistence = EloquentPersistence::create(Connections::url($_ENV['DATABASE_URL']), ['project_dir' => $projectDir]);
$users = $persistence->repositories()->of(App\Entity\User::class);
$user = $users->findOneBy(['email' => 'someone@example.org']);
- The description: the core's
Base\Database\Schema\EntitySchemaover the core'ssrc/Entity, the project's and thedirectoriesgiven - no Doctrine. Options:project_dir,directories, and EntitySchema's (enumerations,types,resolve_target_entities,rules,column_types). - The casts (
OmnibaseCasts): datetime and datetimetz in UTC;arrayunserialized;passkey_base64decoded,passkey_aaguida string,passkey_trust_pathJSON; each enumeration and set through its owntoPHPValue()/toDatabaseValue()(a set reads NULL as[]). - The backend (
EloquentBackend, omnisense/omnibridge'sBackend):name()"eloquent";supports()lazy associations only (no second-level or result cache; transactions with the writes);enableScope('trash')addsdeletedAt IS NULLto every query of a#[Trasheable]entity,enableScope('vault', ['environment' => 'prod'])vault IS NULL OR vault = 'prod'for a#[Vault]one - the core'sTrashScopeandVaultScope, what Doctrine's filters compile -, in the class's WHERE and in the ON of a join of it (the finder's), as Doctrine's filters. - The repositories (
EloquentRepositories, the core'sRepositories):of($class)the core's repository on Eloquent for its entity and its subclasses (Repository\*: each interface ofBase\Repository\Aliases::INTERFACES), an application's own (register()), else the plainEloquentEntities;implementing($interface);byIds(),oneById(). Every repository reads the finder language (findOneByEmailOrUsername(),countByStateAnd...()) andfindBy()'s criteria array as omnibridge/doctrine'sServiceEntityRepositoryreads them - see the finder language. The 14#[BackendOnly]methods (no finder) are written here on Eloquent's connection: omnibridge/doctrine's statements, the same rows.
Writes (Writing entities): through the unit of work, the repositories' persist(), remove() and
flush() as ServiceEntityRepository's, ScopedWeeks::save()/forget() and WeekDays::save() as omnibridge/doctrine's.
The cascades include omnibase's #[Cascade] and #[OrphanRemoval] (OmnibaseCascades); EloquentPersistence's option
dispatcher (PSR-14) receives the Lifecycle events.
Proposer une modification docs/omnibase.md
The finder language
omnibase's finder language - findOneByEmailOrUsername(), cacheOneByStateAndSlugStartingWith(),
countForParentByState(), lengthOfTagsBy(), sumOfViewsByDateGreaterEqualTo()... - is the core's: its
Base\Database\Repository\Finder\Parser reads a method's name and arguments into a neutral Query. A persistence only
compiles that query. EloquentFinder compiles it onto Illuminate's query builder, as omnibridge/doctrine's
ServiceEntityParser compiles it to DQL, so that one name gives the same rows on both. FinderParityTest compares the
two on the same database.
$threads = $persistence->repositories()->of(App\Entity\Article\Article::class); // ThreadRepository's methods
$threads->findByStateAndPublishedAtYoungerThan([ThreadState::PUBLISH], 7 * 86400)->getResult();
$threads->cacheOneBySlug('ruban-rose');
$threads->countByParentAndStateAndWorkflow([12], [ThreadState::PUBLISH], WorkflowState::APPROVED, [], '', null, ['parent']);
$threads->findBy(['parent' => Key::of($thread), Group::by('SlugStartingWithOrSlugStartingWith', 'a', 'b')], ['publishedAt' => 'DESC'], 10);
What is compiled
| The language | On Illuminate's builder |
|---|---|
| EqualTo, NotEqualTo, GreaterThan, GreaterEqualTo, LowerThan, LowerEqualTo | x = ?, <>, >, >=, <, <=. A list gives IN or NOT IN, and an empty list adds no condition. null in a criteria array gives x = NULL, as Doctrine's e.x = :x does: no row meets it (use the word Null, or Key::of(null)). |
| Null, NotNull, Empty, NotEmpty, True, False and their Not | IS NULL, IS NOT NULL, (x IS NULL OR x = ''), (x IS NOT NULL AND x <> ''), = 1, <> 1 |
| YoungerThan, YoungerEqualTo, OlderThan, OlderEqualTo, Within, IsOver, IsNotOver | The same bound as Doctrine's. An age ("+2 days", or seconds) counts back from now, Within's offset counts forward, both rounded to the minute. A date given as text is read as one. IsOver compares with now. The bound is written with the field's type (UTC datetime, date...). |
| StartingWith, EndingWith and their Not; Partial; Insensitive | LIKE / NOT LIKE, the underscore escaped as Doctrine escapes it; Partial LIKE as given; Insensitive LOWER(x) |
| ClosestTo, FarestTo | ORDER BY ABS(x - ?) ascending or descending, after the call's order. The value may name a selected expression (ratio0). |
InstanceOf, ClassOf and their Not (^ reverses one) |
The discriminator among the hierarchy map's values: the class and its subclasses, or the class itself. No condition without a hierarchy. |
| But, WithRoute, Model, Eagerly, cache... | id <> ?; a route parameter (Symfony's request or kernel event, Laravel's request, an array); a Query-by-Example's set fields; Eagerly and cache change no row (the store reads lazily and keeps no result cache) |
An association, or a path through one (translatable.state, tags.slug) |
A LEFT JOIN of it, once. A to-one is joined on its foreign key, a one-to-many or an inverse one-to-one on the target's key, a many-to-many through its join table. The store's conditions (the trash and vault scopes) go into the join's ON, and so does a subclass target's discriminator, as Doctrine's SQL filters do. A Translatable's translations are joined on the translation's own translatable_id, in the root table of its hierarchy (what TranslatableWalker rewrites on Doctrine). |
Key::of($entity | $id | list | null) |
The row's own foreign key, with no join (= ?, IN, IS NULL) |
Group::by('AOrB', ...) |
Its members in parentheses, joined by its separator |
| find, One, All, Randomly, AtMost<N>, Last<N>, LastOne, Previous<N>, PreviousOne, Next<N>, NextOne | The same internal requests and arguments as ServiceEntityParser's (a call Doctrine refuses with a TypeError is refused the same way) |
| count, distinctCount, countFor<Column> | COUNT(x), COUNT(DISTINCT x). Grouped (groupBy or selectAs), the rows ['entity' => ..., <group> => ..., 'count' => ...] |
| lengthOf<Column> | LENGTH(x), or a collection's size (a subquery counting its join table or target rows) |
| sumOf, minOf, maxOf | SUM(), MIN(), MAX() of a field: the database's value, null without a row |
| Order, limit, offset | A field, or an association's field (joined). A list of values gives FIELD() on MySQL and the same CASE elsewhere, bound. id => 'rand' gives RAND() (RANDOM() on SQLite), the terms before it dropped. A selected expression's name, or an expression (below). An offset alone gets MySQL's largest limit, as Doctrine's platform writes it. |
| selectAs, groupBy | Expressions: e.<field>, e_<association>.<field>, numbers, quoted texts, + - * / %, parentheses, ABS, LOWER, UPPER, LENGTH, COALESCE, MOD, ROUND, RAND(), FIELD(), JSON_ARRAY_POSITION() (MySQL's JSON_TABLE as omnibridge/doctrine writes it, json_each() on SQLite) |
| A JSON field | Each value of a list JSON_CONTAINS()ed (AND; NOT for NotEqualTo; LOWER for Insensitive), as Doctrine does; json_each() on SQLite |
A finder that hands back Doctrine's Query hands back a FinderQuery: getResult(), getOneOrNullResult(),
getSQL(), getParameters(), iterable. The results have Doctrine's shapes. There is one entity per root row, at its
first row, as Doctrine's ObjectHydrator keeps it: a join of a to-many association repeats no entity, and the LIMIT
counts SQL rows on both. A mixed row is [0 => entity, alias => value], or ['entity' => ...] when grouped, with the
values as the database gives them, in the order Doctrine selects them.
What is refused aloud (UnsupportedQueryException)
- MemberOf: omnibridge/doctrine writes
e MEMBER OF <class>, which DQL refuses too. - A Partial criterion on an association, or Partial on a list: Doctrine binds it under a name its expression does not use, and refuses the query.
- A path through more than one association, a field the entity does not have, an order on an association itself.
- A JSON field compared through a path, or Insensitive on one JSON value.
- An expression
Expressionsdoes not translate: a parameter, another function, a name that is no field.
The 14 methods declared #[BackendOnly(['doctrine', 'eloquent'])] on the core's interfaces are no finder: the
analytics' upserts, INSERT IGNORE and breakdowns, Redirections::hit()'s bulk UPDATE. Each persistence writes them;
here they are omnibridge/doctrine's statements sent through Illuminate's connection (Repository\Analytics\Statements:
the table wrapped by the grammar, positional bindings, MySQL's or SQLite's form by the driver), and hit() one
UPDATE ... SET hits = hits + 1 on the row - the same rows (tests/Harness and tests/Site BackendWrittenParityTest).
What Doctrine refuses with its own message (an AND/OR ambiguity, an operator a list does not take, an Older/Younger date) is refused with the same message.
The repositories
EloquentEntities is the base of every repository on the store. It provides find(), findAll(), findBy(),
findOneBy(), count() and findByExample() through the finder, and __call() for the language.
EloquentRepositories::of($class) gives the core's repository on Eloquent for its entity and the entity's subclasses
(Repository\*, one per interface of Base\Repository\Aliases::INTERFACES: Threads, Users, Comments, Tags,
ThreadTranslations, the analytics, the hours...). An application registers its own with register(), and any other
class gets the plain EloquentEntities; register($entity, $class, $factory) makes it with a factory when it needs more
than the store (lesringostars' comments' counts take a cache). implementing($interface) gives the repository of a
core interface: the one that declares it before a registered subclass that only inherits it (an application's
ArticleRepository, an Articles and so a Threads, does not answer Threads). Writes
(persist, remove, flush, ScopedWeeks::save()...) go through the unit of work (Writing entities).
Known differences with omnibridge/doctrine
In each of these cases Doctrine refuses something Eloquent compiles. The parity tests record them as known differences, each with its reason:
- An order on a list of texts: Doctrine writes
FIELD(e.state,STATE_DRAFT,STATE_PUBLISH)with the values unquoted, and DQL reads them as names (a semantical error). - RAND() on SQLite: omnibridge/doctrine's
RandwritesRAND(), which SQLite does not have. On MySQL both writeRAND(). - Eagerly on
Thread::$tags(in omnisense/omnibase's harness): Doctrine's fetch join of the cached tags leaves the collection unset (Thread::getTags()returns null: a TypeError).
Proposer une modification docs/finder.md
omnibase's behaviours
Step F7c of omnisense/omnibase's plan "omnibase agnostic", second part: the behaviour attributes (#[Slugify],
#[Timestamp], #[Uploader], #[Vault]...) and the listeners omnibridge/doctrine and omnibase register on Doctrine's
events run on the unit of work's Lifecycle events, at the same moments, and write the same rows.
$persistence = EloquentPersistence::create($connection, ['project_dir' => $projectDir, 'lifecycle' => [
'empty_trash' => '+7 days', 'max_revisions' => 5,
'mention_enhancer' => fn () => $mentionEnhancer, 'setting_bag' => fn () => $settingBag,
'translator' => fn () => $overridingTranslator, 'dispatcher' => $psr14, 'requests' => $requests,
'entity_dispatchers' => [UserEventDispatcher::class, ThreadEventDispatcher::class],
'user_subscriber' => fn () => $userSubscriber, 'entity_extension' => $entityExtension,
]]);
The host wires these from its container (step F7d); tests/Support/OmnibaseHarness does it from a Symfony test
container.
The relay (Omnibase\AttributeRelay)
omnibridge/doctrine's AttributeSubscriber on the unit of work (a listen() listener), kept as it is:
- an entity's moment (PrePersist ... PostRemove, PostLoad): each property attribute, then each class attribute, that supports the entity, gets its neutral hook;
- the flush's moments over its buffers: PreFlush and OnFlush both append the scheduled insertions, updates and deletions without emptying them (an entity persisted before the flush is there twice at OnFlush, and its OnFlush hooks run twice, as on Doctrine); a property attribute runs when its property changed or, for a new entity without an identifier, when the entity has it; PostFlush walks the candidates and the buffers, then empties them;
- an attribute overriding a Doctrine-typed hook of the moment (
onFlush(OnFlushEventArgs ...): omnibase/marketplace'sOrderReference) is refused aloud (UnsupportedPersistence).
The behaviours' parts (Omnibase\EloquentBehaviours)
Registered with omnibase's Backends for EloquentUnitOfWork, where omnibridge/doctrine's DoctrineBehaviours
registers Doctrine's:
| Part | On Eloquent |
|---|---|
VaultBackend |
scheduleForUpdate(): the entity updated whatever its change set says |
SlugifyBackend |
the scheduled insertions then updates; a unique index on the column alone read through Illuminate's schema builder |
CacheBackend |
no second-level cache: nothing evicted; the cached targets' associations read through their getters, as Doctrine's backend reads them |
OrderColumnBackend |
a LazyCollection holds its elements, once read, in omnibase's OrderedArrayCollection |
EntityShapes |
the store's description (DescriptionShapes) |
RowLocks |
a transaction and SELECT ... FOR UPDATE |
ConditionalUpdates |
UPDATE ... WHERE id = ? AND field = ? |
EntityCache |
disabled |
BulkDeletes |
DELETE by criteria, every table of a joined hierarchy (deleteIds() too) |
ColumnTypes |
json and the set enumerations |
Backend, Repositories |
the persistence's own |
The listeners (Omnibase\Lifecycle\)
Listening in Doctrine's order (priority, then declaration): the relay and IntlListener (2048), TrasheableListener
(1024), VersionableListener (512), then ThreadListener, SettingListener, TextOverrideListener, one
EntityDispatcherListener per entity dispatcher, PreFlushListener (UserSubscriber's preFlush()),
DemoAccountLockListener.
| Doctrine listener | Eloquent | Rule (the core's) |
|---|---|---|
| TrasheableSubscriber | TrasheableListener: the stamp, the deletion taken back, an extra update with the pending changes, the cascade rescued, the trash entry inserted |
TrashRecorder |
| VersionableSubscriber | VersionableListener: a revision per anchor at OnFlush, the old ones deleted after the flush |
RevisionRecorder |
| IntlSubscriber (flush, postLoad) | IntlListener |
- |
| ThreadSubscriber (prePersist, onFlush) | ThreadListener; the mentionees read again from the store (StoreMentionees) |
ThreadParticipants |
| SettingSubscriber | SettingListener |
SettingsInvalidation |
| TextOverrideCacheListener | TextOverrideListener |
- |
| the entity dispatchers (user.*, thread.*, an application's) | EntityDispatcherListener: doctrine/persistence's LifecycleEventArgs over an EloquentObjectManager, a PreUpdate's with its changes (PreUpdateArgs) |
- |
| UserSubscriber's preFlush | PreFlushListener |
- |
| DemoAccountLockSubscriber | DemoAccountLockListener |
DemoAccountLock |
Not adapted, with the reason: InverseCollectionCacheSubscriber and TrasheableSubscriber's eviction (Doctrine's
second-level cache: the store keeps none); TrackingPolicySubscriber, the listeners' loadClassMetadata and
resolveDiscriminator (Doctrine's mapping: the description is the store's; the translations' cascades are
OmnibaseCascades'); IntlSubscriber's onQuery (the finder joins the translations itself). Refused aloud:
ExtensionSubscriber's entries when an EntityExtension holds an extension (none is registered by omnibase, its bundles
or lesringostars), an entity dispatcher overriding the Doctrine-typed postFlush(), the entity dispatchers without
doctrine/persistence, EloquentObjectManager's metadata and repositories.
Known differences
TrasheableListenercompares an enumeration field as the entity holds it; Doctrine's subscriber reads its backing value and finds it changed: the same column written, Doctrine's next flush of that entity updating it once more.
Proposer une modification docs/behaviours.md
Testing
The package's suite
A Docker harness of its own (PHP 8.4 with SQLite, intl, bcmath): compose.yml installs the packages, PHPUnit runs.
omnisense/omnibase and omnibridge/doctrine are in require-dev - the core's entities, and Doctrine to compare with.
docker compose run --rm test # the published packages
docker compose -f compose.yml -f compose.checkouts.yml run --rm test # the family's checkouts mounted over them
| Test class | What it checks |
|---|---|
EntityStoreTest |
the fixtures (tests/Fixtures/Entity: a joined hierarchy Thread/Article/Page, a single-table one Animal/Cat/Dog, many-to-one, inverse one-to-one, one-to-many ordered and indexed, many-to-many owning and inverse; string, text, integer, bigint, boolean, float, decimal, json, simple_array, datetime, datetime_immutable, date) on SQLite in memory, their tables created from the description by omnisense/omnibridge's SchemaCreator and their rows written by Doctrine: every entity read by id - by its class and by its hierarchy's root - equal to Doctrine's once normalised; a to-one association a ghost until a field is read (its identifier read without a query), read at once when its target has subclasses; collections read on first use, ordered, indexed; a single-table class reading only its rows; findMany()'s order; a missing row null, a ghost of it EntityNotFound; the unit of work answering what it read; a condition on every query |
WriteParityTest |
the writes on both backends: the same calls on the fixtures (with an identifier the application gives, Setting, and cascades: Thread's translations persist/remove/orphanRemoval, User's profile persist) through Doctrine's entity manager on one SQLite database and through EloquentUnitOfWork on another, both created from the description: every column of every table equal (the generated identifiers included: the same insert order), and the same lifecycle events in the same order (each one's entity, a PreUpdate's changed fields). Scenarios: a graph inserted in commit order (joined and single-table hierarchies, discriminators, join rows, mandatory and nullable foreign keys, cascades through a one-to-many and an inverse one-to-one); fields, to-one associations, join rows added, taken out and a collection replaced, an orphan removed, a second flush writing nothing; deletes cascaded and ordered, a removed entity persisted again; a nullable cycle broken by an extra update; a listener's setNewValue() and an OnFlush insertion; a new entity reached without cascade refused by both; a transaction rolled back; isNew/contains/identifier along the way. Plus: snapshots and changes during a flush, a collection the application made becoming a LazyCollection, a failed flush closing the unit of work |
| ConnectionsTest | a database URL read as Illuminate's configuration; Capsule's connection, a connection on an open PDO (the same database), a Laravel database manager's (Capsule's manager answers the same ConnectionResolverInterface as app('db')) |
| Omnibase\OmnibasePartTest | with the core installed: omnibase's column types (array, the passkeys', UTC datetime, an enumeration); the core's Repositories/Entities on the store (a criteria array through the finder: a field, an association by entity or identifier, IN, = NULL as omnibase's finder compares it, order, limit; byIds() in order; a repository made without a unit of work refusing to write); the backend's name, features and scopes |
| Omnibase\FinderTest | the finder language compiled by EloquentFinder on the fixtures: each word of a criterion, the associations joined (to-one, many-to-many, one-to-many, a path) and the key not, the discriminator words, the groups and separators, the requests and their shapes (a FinderQuery, Last/AtMost/Next/Previous, counts, a grouped count's rows, lengths, sumOf/minOf/maxOf, a selected value), the orders (FIELD, a path, RANDOM, limit and offset), what is refused aloud |
| Omnibase\RepositoriesTest | the core's entities described with no Doctrine: each of the 28 repository interfaces of Aliases::INTERFACES implemented on the store, a subclass given its parent's repository, a registered one; the 14 #[BackendOnly] methods run on SQLite (ON CONFLICT, INSERT ... ON CONFLICT DO NOTHING, hits + 1), refused for a persistence the declaration does not name; a registered repository's factory; ScopedWeeks::save()/forget() and WeekDays::save() through the unit of work, read back |
| Omnibase\OmnibaseCascadesTest | omnibase's #[Cascade] and #[OrphanRemoval] on a property and on its class, over the mapping's, a parent keeping its own |
tests/Support/Normaliser.php: an entity as plain data - its class, its mapped fields (a date as its class, time and
zone), its to-one associations as class and identifier (read without loading them), its to-many associations as the
list of them, sorted unless the association declares an order, keyed when it is indexed.
Comparing with Doctrine on an application's database
tests/Site/ByIdParityTest.php runs in an omnibase application's Symfony test kernel. The package is copied into the
application's vendor with its own vendor/ (nothing of the application's lock changes); tests/Site/autoload.php
appends a class loader after the application's, so a class the application has stays the application's.
rsync -a --exclude=.git omnibridge/eloquent/ app/vendor/omnibridge/eloquent/
OMNIBRIDGE_ELOQUENT_SITE=1 php bin/phpunit vendor/omnibridge/eloquent/tests/Site/ByIdParityTest.php
testEveryEntityReadByIdIsDoctrinesOnTheSitesData: for each entity class the core'sEntitySchemadescribes, a sample of the rows exactly of that class (OMNIBRIDGE_ELOQUENT_SAMPLE, 5), read by Doctrine's entity manager and by the store on its PDO, compared normalised - with every scope off, then with the trash scope on both sides. Read only. Report:var/eloquent-by-id.json.testEveryEntityClassReadByIdOnARowWrittenForIt: the classes the site's data holds no row of - in one transaction, rolled back, a row written for each class that is not abstract, under a negative identifier (so no auto-increment counter moves): each column a value of its type (a nullable text left null, an enumeration a value the site's rows hold, a PHP enumeration its first case, JSON an empty list...), each nullable association null, each required one pointing at a row of its target (the site's, else one written before it, so no cycle); a unique join column gets a target row of its own, a copy of a real row when the class has one; each many-to-many holding a row; foreign keys unchecked for the session. Each row read by id on both sides. Report:var/eloquent-by-id-written.json.- Since step F7c's second part the store runs the behaviours' PostLoad (the relay,
lifecycleoption): a difference only a postLoad hook makes (omnibase's#[OrderColumn]writing User::$rolesPositions back) counts as a difference; the report'spostLoad onlystays empty, and users are asserted compared. OMNIBRIDGE_ELOQUENT_TRACE=1writes where a long run is tovar/eloquent-by-id.trace.
The finder parity (tests/Site/FinderParityTest.php)
In the same application kernel: each finder call on omnibridge/doctrine's repositories and on omnibridge/eloquent's, the
same MySQL database and transaction (rolled back), compared normalised (Support\FinderParity: entities by class and id,
in order; a query with no ORDER BY may give the same rows in another order; both refusing; a known difference with its
reason; anything else unequal, which fails the test):
- the application's FinderParityTest calls (on lesringostars: its own and the core's on its path, one of each word: 61); a method of the application's own repositories is called on Doctrine as written and on Eloquent as the finder it is written on (the application's repositories on Eloquent are step F7f);
- the rewritten queries of step E2 (its
App\Tests\Repository\HandWrittenQueries' "after" methods: 48, and the application's 3); - Comments' findVisible()/countVisible() on comments written under a thread put in the trash (9);
- one of each word and of what the compiler adds - paths through associations and translations (the Translatable join), the key, groups, the aggregates, grouped counts and lengths, FIELD(), RAND() - on the application's rows (read only).
OMNIBRIDGE_ELOQUENT_SITE=1 php bin/phpunit vendor/omnibridge/eloquent/tests/Site/FinderParityTest.php
Report: var/eloquent-finder-parity-site.json.
The finder parity in omnisense/omnibase's harness (tests/Harness/FinderParityTest.php)
The core's harness (its demo application: the core's, the demo's and the plugins' entities, Doctrine on SQLite), the
package mounted at /srv/omnibridge/eloquent with its own vendor/ (an extra compose file):
services:
omnibase:
volumes:
- ../../omnibridge/eloquent:/srv/omnibridge/eloquent:ro
docker compose -f compose.yml -f compose.checkouts.yml -f compose.eloquent.yml run --rm omnibase \
test --filter FinderParityTest /srv/omnibridge/eloquent/tests/Harness/FinderParityTest.php
On rows it writes in a transaction rolled back (threads with states, dates, parents, tags, one in the trash): the core's
FinderParityTest scenarios and one of each word (128 calls), then the core's HandWrittenQueries' "after" methods on
both (42, with the rows the core's own test writes). The harness has no MySQL driver: MySQL is compared on the
application. Report: var/eloquent-finder-parity-harness.json.
Made-up values in a thread's row send Doctrine's own read into an endless loop (seen on lesringostars, not investigated further: Doctrine's side, the row is the test's): that is why rows are copied from real ones where they exist and the made-up values stay plain.
omnibase's behaviours on both backends (step F7c, second part)
-
tests/Omnibase/LifecycleTest: what needs no host - a Doctrine-typed hook and anEntityExtensionrefused aloud,LazyCollection's changes as PersistentCollection's (dirty, added before read, an owning side cleared),wrap(). -
tests/Harness/WriteParityTest, in omnisense/omnibase's harness (SQLite; the package mounted by the core'sdocker/compose.eloquent.yml): each scenario through Doctrine's unit of work in a transaction rolled back, then through this package's with omnibase's lifecycle attached (tests/Support/OmnibaseHarness, from the harness's container), every column of every table it touches compared - identifiers as given, dates relative to the start, what is random by its shape, a password hash by the password it verifies, a sealed value by what it opens to, an upload by its content. Timestamp, Blameable and the trash; Slugify and GenerateUuid; Randomize and Cache; OrderColumn and the revisions; Hashify; Vault; the passkeys' records; Uploader (its files removed after: a scratch directory); orphan removal's cascade (an account removed with its connections). Report:var/eloquent-write-parity-harness.json.docker compose -f compose.yml -f compose.checkouts.yml -f compose.eloquent.yml run --rm omnibase \ test --filter WriteParityTest /srv/omnibridge/eloquent/tests/Harness/WriteParityTest.php -
The core's behaviour tests run on both backends with the same overlay (
Tests\Base\Database\Backend\BothBackends: OrderColumnLazyTest, SlugifyPerTypeTest, UploaderSplFileInfoTest, EmptyTrashTest, ThreadParticipantsTest, BehaviourBackendsTest). -
tests/Site/WriteParityTest, on an application's database (lesringostars): an Article and its translation written on both (Countify, Slugify, GenerateUuid, Timestamp, a revision), in transactions rolled back, the new rows compared with their identifiers numbered per hierarchy.tests/Site/ByIdParityTestreads with the relay attached: the behaviours' PostLoad runs on both sides (User::$rolesPositions included, asserted).
An application's write flows on both backends (step F7c, third part)
tests/Site/FlowParityTest.php runs lesringostars' write flows through the services its controllers call - omnibase's
rules (Registration, Verification, Passwords, omniauth with omnibase's stores and the sign-in's listeners
TrackConnections, WarnNewDevice, UpdateAccount, Presence), omnibase's neutral FormProcessor and
EntityHydrator on the site's form definitions, the site's CommentPosting, NewsletterSubscription, newsletter
actions and SettingBag -, each made with one backend's unit of work and repositories (tests/Support/Site/FlowSide:
Doctrine's from the container, or this package's with omnibase's lifecycle, OmnibaseHarness; lesringostars' two App
repositories the flows reach have test doubles on the finder, EloquentSubscribers and EloquentDestinations, until
step F7f). No HTTP. The flows: the sign-up (form, registration, the verification link asked and followed, the
approval, a password reset), the sign-in (a wrong password, the right one, a link from another browser, the presence),
a comment and a reply, the newsletter's form twice, the trash (the newsletter left, then back) and a vaulted setting
written and read, an article's tags (a many-to-many with an #[OrderColumn]), an account's links (cascaded, ordered,
an orphan removed) and a gallery's photos reordered, a setting written.
Every table is fingerprinted (tests/Support/RowChanges: the primary key and an MD5 of all the columns of each row)
before the flow and after each of its steps; the rows inserted, updated and deleted are read from the difference, with
the times written during the flow relative to its start and, in an inserted row, what is random by its kind (a
password's hash, a token's value, a uuid, a comment's random slug, a vaulted value's cipher - its clear value is read
back by the flow). Each backend runs in a process of its own on its own copy of the application's test database (one
dump: the same rows and auto-increments, so identifiers compare as written), then the two records are compared, step by
step and column by column, with what the flows read back (var/eloquent-flows/report.json). The Eloquent run counts
any write through Doctrine (0 expected).
DOCTRINE_TEST_DATABASE=f7c3_doctrine OMNIBRIDGE_ELOQUENT_FLOWS=doctrine php bin/phpunit vendor/omnibridge/eloquent/tests/Site/FlowParityTest.php
DOCTRINE_TEST_DATABASE=f7c3_eloquent OMNIBRIDGE_ELOQUENT_FLOWS=eloquent php bin/phpunit vendor/omnibridge/eloquent/tests/Site/FlowParityTest.php
OMNIBRIDGE_ELOQUENT_FLOWS=compare php bin/phpunit vendor/omnibridge/eloquent/tests/Site/FlowParityTest.php
The flows write only to a database named f7c3_* (asserted). Doctrine's second-level cache is emptied with its
identity map between steps, so that a read says what was written (Eloquent has none).
The methods no finder compiles, an application's repositories (step F7f)
tests/Harness/BackendWrittenParityTest(omnisense/omnibase's harness, SQLite) andtests/Site/BackendWrittenParityTest(an application's MySQL database, a scratch copy of it): the 14#[BackendOnly]methods on both backends (Support\BackendWrittenParity) - the ten reads for windows and page filters on the rows there, the four writes run alike in a transaction rolled back, their rows (without identifiers) and the reads of their window compared. On SQLite,Visits::returning()is a known difference: omnibridge/doctrine's aliasreturningis a keyword there.tests/Site/AppRepositoriesParityTest: lesringostars' eleven App\Repository interfaces and its accounts' provider, each method on its Doctrine repository and on App\Persistence\Eloquent's, registered as the site registers them.
DOCTRINE_TEST_DATABASE=f7f_lesringostars_test OMNIBRIDGE_ELOQUENT_SITE=1 php bin/phpunit vendor/omnibridge/eloquent/tests/Site/BackendWrittenParityTest.php
DOCTRINE_TEST_DATABASE=f7f_lesringostars_test OMNIBRIDGE_ELOQUENT_SITE=1 php bin/phpunit vendor/omnibridge/eloquent/tests/Site/AppRepositoriesParityTest.php
Proposer une modification docs/testing.md
The persistence of a host
omnibridge/eloquent runs where omnibase runs: under Symfony, under Laravel, in plain PHP (omnisense/omnibase's plan
"omnibase agnostic", step F7d). The persistence is chosen as every host chooses it - omnisense/omnibridge's
Persistence\Persistences: the only persistence installed, else the one the host's option names; omnibridge/doctrine
and omnibridge/eloquent both installed with no option is refused aloud, never one picked for you (R8). Where the
option names none, the environment variable OMNIBRIDGE_PERSISTENCE may.
| Host | Option | Wired by |
|---|---|---|
| Symfony | base: { persistence: eloquent } (config/packages/base.yaml) |
omnibridge/symfony |
| Laravel | 'persistence' => 'eloquent' (config/base.php) |
omnibridge/laravel |
| plain PHP | the parameter persistence of Omnibridge\Container::fromDefinitions() |
Omnibridge\Eloquent\Container::definitions() |
What a host gets
Behind the same contracts as Doctrine's:
Omnibridge\Persistence\UnitOfWorkandSnapshots: theEloquentUnitOfWork;Omnibridge\Persistence\Backend: theEloquentBackend, its trash scope on and its vault scope for the environment, as omnibase's Doctrine mapping enables its filters;Base\Database\Repository\Repositories: theEloquentRepositories;- each of the core's repository interfaces (
Base\Discovery'srepositories): the repository implementing it,Base\Repository\Usersreading the application's users (App\Entity\User) - Security's user loader and provider too (loadUserByIdentifier(),refreshUser(),supportsClass(),upgradePassword(), as omnibridge/doctrine's).
EloquentPersistence::host($connection, $services, $options) makes it: omnibase's lifecycle is attached from the host's
services (EloquentPersistence::HOST_SERVICES: the AttributeReader, the mention enhancer, the setting bag, the
overriding translator, UserSubscriber, the demonstration lock, the entities' extension, the requests), each found by
has() when the persistence is made and asked by get() when it is first needed - most are made with this
persistence's unit of work. The entity dispatchers are the host's own services (an iterable or a closure, read at the
first entity event): they live as long as the container that holds the persistence, so the host's flush of what they
left pending never reaches another persistence's unit of work. reset() clears the unit of work between two requests
(Symfony's kernel.reset), as Doctrine's registry resets its entity manager.
Without an AttributeReader (omnibase builds it on Doctrine's metadata: none in plain PHP) the behaviours cannot run:
the entities are read as stored, and a flush that writes is refused aloud (UnsupportedPersistence).
Symfony
omnibridge/symfony registers the services when base.persistence is eloquent: the connection is Doctrine's PDO when
DoctrineBundle is installed beside (one database, one transaction), else Capsule on DATABASE_URL; the entities are
those DoctrineBundle maps (their folders, target entities, column types), else the core's and src/Entity. An entity
user provider of security.yaml for the application's users (no property) becomes the users' repository on Eloquent.
The core's services name its repository interfaces, so they answer from Eloquent; the entities' UniqueEntity rules look
an entity Eloquent read up through Eloquent. Eloquent and Doctrine share one PDO: a flush joins a transaction Doctrine
began on it, and the connection lets the PDO go when the kernel shuts down. What still names Doctrine's services by
their ids stays on Doctrine: the back office (EasyAdmin), the console commands named after Doctrine, what reads
Doctrine's metadata only. An application's own repositories follow the switch when it gives them Eloquent
implementations and registers them (EloquentRepositories::register()): lesringostars' App\Persistence\Eloquent,
chosen by a compiler pass reading base.persistence (its classes cannot be loaded without this package, so no
#[AsAlias] can name them), and by its twin's service provider under Laravel. The methods no finder compiles
(#[BackendOnly]: the analytics' native SQL, Redirections::hit()) run here in their Eloquent form.
Laravel
omnibridge/laravel answers the same contracts from Eloquent when config/base.php says so, on Laravel's own connection
(omnibridge.eloquent.connection, else omnibridge.doctrine.connection, else the default one); the account's effects
(the connections' tracker, the sign-in listeners, omniauth's stores) write through the unit of work chosen. omnibase's
entity manager is still made at boot, for the AttributeReader it registers; the crops and the push subscriptions are
read through the core's interfaces. Neither unit of work is cleared between two requests one application serves (a
test): a test that wants each request fresh clears the persistence chosen (EloquentPersistence::reset()), not only
Doctrine's entity manager.
Plain PHP
$container = Omnibridge\Container::fromDefinitions([], [
'persistence' => 'eloquent',
'kernel.project_dir' => '/srv/app', // the entities of src/Entity and the core's
'database.url' => getenv('DATABASE_URL'), // Capsule (or give an Illuminate\Database\Connection service)
]);
$articles = $container->get(Base\Database\Repository\Repositories::class)->of(App\Entity\Article\Article::class)->findAll();
EloquentPersistence::fromContainer() reads the container's connection or database.url or DATABASE_URL, its
Omnibridge\Schema\Description or the entities' folders, base.extension.empty_trash/max_revisions, the services
tagged base.entity_dispatcher, and only the services the application defined (Omnibridge\Container answers has()
for any class it could autowire). tests/Site/articles.php lists lesringostars' articles so, with no Symfony, no
Laravel and no Doctrine entity manager.
Proposer une modification docs/hosts.md
Writing
EloquentUnitOfWork writes the entities the store reads, as Doctrine's unit of work writes them: the same calls on
the same entities leave the same rows (WriteParityTest compares every column of every table, the identifiers the
database gave included) and dispatch the same lifecycle events in the same order. It is omnisense/omnibridge's
UnitOfWork and Snapshots.
$unitOfWork = new EloquentUnitOfWork($store, $dispatcher); // a PSR-14 dispatcher, optional
$unitOfWork->listen(fn (object $event) => ...); // Lifecycle\PrePersist ... PostFlush, PostLoad
$article = $store->find(Article::class, 12);
$article->setTitle('Zanzibar');
$unitOfWork->persist($comment = new Comment($article));
$unitOfWork->flush();
persist(), remove()
persist(): a new entity is scheduled for insertion (PrePersist), and so are the new entities its associations reach where they cascade the persist. An identifier the application gives (no generated value) must be set by then; the entity is kept in the identity map at once. A removed entity persisted again is taken back.remove(): the entities the associations cascade the removal to first (a collection never read is read), then the entity scheduled for deletion (PreRemove). A new entity removed is no longer inserted.- The cascades are the mapping attribute's
cascadeandorphanRemoval(Cascades, through omnisense/omnibridge's declaration reader: no ORM loaded); omnibase's#[Cascade]and#[OrphanRemoval]are read as well (Omnibase\OmnibaseCascades, added byEloquentPersistence). Orphan removal on a one-to-many or one-to-one implies the removal's cascade, as Doctrine completes the mapping: an owner removed takes the association's entities with it (omnibase'sUser::$connections).
flush()
In Doctrine's order (UnitOfWork::commit()):
PreFlush.- The change sets. A new entity: each field (an identifier the database gives left out) and each owning to-one
association. A managed entity: each value compared with what it held when it was read or last flushed (the store's
snapshot), strictly -
!==, an object by identity, an enumeration by its value -, a collection read and put out of the entity included (Doctrine signals it there). The associations: a new entity reached is persisted when the association cascades the persist (aPrePersist), and refused otherwise (InvalidPersistence::notCascaded(), Doctrine's message); an owning many-to-many collection is compared with its snapshot, and its owner, with no other change, is updated with an empty change set (itsPreUpdate, as Doctrine does); the entities taken out of an orphan-removing association (or replaced in an orphan-removing to-one) are removed. OnFlush: a listener may persist, remove, change,recompute(), or schedule an extra update.- In one transaction:
- the inserts in commit order (
CommitOrder, Doctrine's topological sort): an entity after the new entities its foreign keys point to. A nullable foreign key in a cycle is written NULL, then set by an extra update; a cycle of mandatory ones is refused. The root's table first with the discriminator, the identifier the database gives read back and set on the entity, then each table a joined class adds.PostPersistfor each, once all are written; - the updates by class and identifier, each after its
PreUpdate: a value a listener gave withsetNewValue()is written, and the listener's own changes to the entity are recomputed, as Doctrine recomputes afterpreUpdate.PostUpdatefor each; - the extra updates;
- the join tables' rows: those of the targets taken out deleted, those added inserted; a collection put in place of the one read replaces all its rows;
- the deletes, an entity before those its foreign keys point to (but a
SET NULLone): the join tables' rows of its many-to-many associations (but those the database cascades: Doctrine'sisOnDeleteCascade), then the root table's row (the tables a joined class adds go with it by their foreign key). The identifier the database gave is set back to null.PostRemovefor each, once all are deleted.
- the inserts in commit order (
- The snapshots taken again: a collection the application made becomes a
LazyCollectionholding the same elements (Doctrine puts aPersistentCollectionthere); the deleted entities leave the collections that held them. An entity inserted or updated keeps, for its fields and to-one associations, what it held when its change set was computed (or recomputed afterPreUpdate), not what thePostPersist/PostUpdatelisteners left: Doctrine's original data. A change those listeners make (orPostFlush's) is written by the next flush, as on Doctrine (seen on lesringostars' sign-up:User::$rolesPositions,[0]when inserted,[]once the behaviours ran, is written[]by the next flush on both).PostFlush.
A flush that finds nothing to write dispatches OnFlush and PostFlush only. A flush that fails is rolled back and
closes the unit of work (UnsupportedPersistence::closed()), as Doctrine closes its entity manager.
The rest of the contract
transactional($work): the work, then a flush, in one transaction; rolled back (and closed) when it throws.isNew(),contains(),identifier(): as Doctrine answers them before and after each step.changes(): the entity's change set while a flush runs ([]outside one);recompute(): Doctrine'srecomputeSingleEntityChangeSet()- the first change set of an entity persisted during the flush.original()(Snapshots): the fields and to-one associations as read or last flushed.scheduledInsertions(),scheduledUpdates(),scheduledDeletions(),scheduleExtraUpdate(),cancelDeletion(): what a listener ofOnFlushreads and does on Doctrine's unit of work. Since the behaviours (step F7c, second part; omnibase's behaviours):scheduleForUpdate()(a forced update, its PreUpdate dispatched),scheduleForDelete()(no PreRemove, nothing cascaded),propertyChanged(),setOriginal(),scheduledCollectionUpdates()(the collections changed, as Doctrine's dirty PersistentCollections),scheduleOrphanRemoval(),cancelOrphanRemoval(),recomputeWithAssociations()(Doctrine'scomputeChangeSet()on a managed entity),detach(),isUninitializedObject().LazyCollectiontells whether it changed since read (isDirty(), as PersistentCollection's: an element taken out that was not there changes nothing), keeps an element added before it is read and puts it after the elements read (no read for it), and on an owning many-to-many'sclear()takes its snapshot (empty: the owner's rows all go at the flush,wasCleared()) and is not dirty until an element is added - Doctrine's. A cleared and refilled owning collection updates its owner with an empty change set (its PreUpdate runs), as Doctrine's dirty collection does.wrap()gives the elements, once read, to another collection (omnibase'sOrderedArrayCollection).PostLoadfor each entity the store reads, once the read is done, in Doctrine's order (after the entities a row makes it read at once through an owning association, before an inverse one-to-one read for it).
Refused aloud
UnsupportedPersistence: an identifier generated otherwise than by the database (AUTO, IDENTITY) or the application (NONE) - a sequence, a custom generator; a composite identifier, join column or join table; a flush from a listener of a flush; a write after a failed flush; a repository made without a unit of work.InvalidPersistence(anInvalidArgumentException, where Doctrine throws itsORMInvalidArgumentException): a new entity reached through an association that does not cascade the persist, a cycle of mandatory foreign keys, an identifier the application must give and did not, an entity of no described class, a value of the wrong class in an association.
Differences kept
- A collection Doctrine marks dirty and then puts back as it was (add then remove the same element) is compared by its
elements for its join rows; since step F7c's second part it is dirty as Doctrine's, so its owner gets the same empty
PreUpdate. - An orphan-removing one-to-many replaced whole: the elements not in the new collection are removed through the unit
of work (their
PreRemove), where Doctrine deletes every row of the owner by SQL, those of the new collection too.