Skip to content

omnibridge/eloquent

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: tables from the description, a hydrator with PHP 8.4 lazy ghosts and lazy collections, an identity map, casts; and omnibase's persistence on Eloquent (Omnibridge\Eloquent\Omnibase\: its backend, scopes, repositories and finder compiler) when omnisense/omnibase is installed.

Floor
Bridge
Version
1.x
Verified for real
Its README does not say
Licence
MIT
composer require omnibridge/eloquent:1.x-dev

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".

Propose a change 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 (EntityNotFound if 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 aliased t0 (the root's), t1...; $map->column('title') is t0.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's Types::register());
  • ->with('money', $toPhp, $toDatabase): an application's own type;
  • the ColumnTypes given: 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).

Propose a change 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\EntitySchema over the core's src/Entity, the project's and the directories given - 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; array unserialized; passkey_base64 decoded, passkey_aaguid a string, passkey_trust_path JSON; each enumeration and set through its own toPHPValue() / toDatabaseValue() (a set reads NULL as []).
  • The backend (EloquentBackend, omnisense/omnibridge's Backend): name() "eloquent"; supports() lazy associations only (no second-level or result cache; transactions with the writes); enableScope('trash') adds deletedAt IS NULL to every query of a #[Trasheable] entity, enableScope('vault', ['environment' => 'prod']) vault IS NULL OR vault = 'prod' for a #[Vault] one - the core's TrashScope and VaultScope, 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's Repositories): of($class) the core's repository on Eloquent for its entity and its subclasses (Repository\*: each interface of Base\Repository\Aliases::INTERFACES), an application's own (register()), else the plain EloquentEntities; implementing($interface); byIds(), oneById(). Every repository reads the finder language (findOneByEmailOrUsername(), countByStateAnd...()) and findBy()'s criteria array as omnibridge/doctrine's ServiceEntityRepository reads 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.

Propose a change 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 Expressions does 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 Rand writes RAND(), which SQLite does not have. On MySQL both write RAND().
  • 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).

Propose a change 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's OrderReference) 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

  • TrasheableListener compares 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.

Propose a change 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's EntitySchema describes, 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, lifecycle option): a difference only a postLoad hook makes (omnibase's #[OrderColumn] writing User::$rolesPositions back) counts as a difference; the report's postLoad only stays empty, and users are asserted compared.
  • OMNIBRIDGE_ELOQUENT_TRACE=1 writes where a long run is to var/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 an EntityExtension refused 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's docker/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/ByIdParityTest reads 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) and tests/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 alias returning is 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

Propose a change 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\UnitOfWork and Snapshots: the EloquentUnitOfWork;
  • Omnibridge\Persistence\Backend: the EloquentBackend, its trash scope on and its vault scope for the environment, as omnibase's Doctrine mapping enables its filters;
  • Base\Database\Repository\Repositories: the EloquentRepositories;
  • each of the core's repository interfaces (Base\Discovery's repositories): the repository implementing it, Base\Repository\Users reading 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.

Propose a change 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 cascade and orphanRemoval (Cascades, through omnisense/omnibridge's declaration reader: no ORM loaded); omnibase's #[Cascade] and #[OrphanRemoval] are read as well (Omnibase\OmnibaseCascades, added by EloquentPersistence). 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's User::$connections).

flush()

In Doctrine's order (UnitOfWork::commit()):

  1. PreFlush.
  2. 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 (a PrePersist), 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 (its PreUpdate, as Doctrine does); the entities taken out of an orphan-removing association (or replaced in an orphan-removing to-one) are removed.
  3. OnFlush: a listener may persist, remove, change, recompute(), or schedule an extra update.
  4. 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. PostPersist for each, once all are written;
    • the updates by class and identifier, each after its PreUpdate: a value a listener gave with setNewValue() is written, and the listener's own changes to the entity are recomputed, as Doctrine recomputes after preUpdate. PostUpdate for 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 NULL one): the join tables' rows of its many-to-many associations (but those the database cascades: Doctrine's isOnDeleteCascade), 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. PostRemove for each, once all are deleted.
  5. The snapshots taken again: a collection the application made becomes a LazyCollection holding the same elements (Doctrine puts a PersistentCollection there); 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 after PreUpdate), not what the PostPersist/PostUpdate listeners left: Doctrine's original data. A change those listeners make (or PostFlush'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's recomputeSingleEntityChangeSet() - 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 of OnFlush reads 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's computeChangeSet() on a managed entity), detach(), isUninitializedObject().
  • LazyCollection tells 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's clear() 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's OrderedArrayCollection).
  • PostLoad for 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 (an InvalidArgumentException, where Doctrine throws its ORMInvalidArgumentException): 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.

Propose a change docs/writing.md