Database
Amarant framework features a hybrid active record object relational mapper (ORM). Data is mapped using data models. Each data model is bound to a single database table.
The supported database type is PostgreSQL. See system requirements.
Database migrations are written using pure SQL for maximum flexibility.
Note
When referring to field or column, we always mean a colum in a database table.
Connections
Connections are abstractions that enable access to the database. Internally, each connection uses an adapter to communicate with the database. An adapter then connects to the database.
By default, the application has one default connection configured, using the configuration mentioned here.
The default connection is bound using binding identifier db.connection.default.
Creating a connection
If needed, you can create additional connections using dependency injection configurator.
Check the annotations to know more.
- Create an adapter.
- The adapter implementation to use.
- Inject parameters from the environment.
- Optionally, add PDO options.
- Create a connection.
- The connection implementation to use.
- Inject the newly created adapter.
- Add connection to connection pool constructor parameter.
- The name of the new connection in the pool.
The connection can now be fetched from the pool like this:
$customConnection = $connectionPool->getConnection('custom');
Note
Connections will not establish a connection to the database in advance, but only when the connection is used to perform some query.
Data models
When data is loaded from the database, it's hydrated to a data model. Also, the data from a data model can be persisted to the database. Multiple hydrators and persisters can be used in this process.
When data models are loaded, saved or deleted, multiple events are fired, allowing event subscribers to access the data model, check what fields have changed, etc.
A data model can store data which is not a part of its table schema. By default, and when using only the default persister, only the properties that exist in the table schema will be persisted.
The reason for this is to allow multiple hydrators to enrich the data model with additional data. Conversely, to allow multiple persisters to save additional data, for example relational data to other tables.
Create a data model
Check the annotations to know more.
- Add method annotations for the IDE.
- Extend the base data model
Amarant\Framework\Data\Database\DataModel. - It's recommended that you define table fields as public constants or an enum to be able to use them when building queries.
- This is the only method that must be implemented when extending the base data model. It should correspond to a table name in the database that this data model will be bound to.
To additionally customize the data model, override methods from the base model.
Note
When getting or setting data on a data model, always use get or set and the name of a field in PascalCase as
the method name.
For example, a table field named some_field, its getter method is getSomeField, while the setter is
setSomeField.
You can also use the is or has methods.
The isSomeField would return the value of the field as boolean, while hasSomeField would return if
the data model has a value present for the field, as a boolean.
Primary key
To set the primary key field name, override the method getIdField.
By default, the name is id. To fetch the value of the primary key, use getIdValue method.
Discriminator
Defines a discriminator column (table field) name and a discriminator map which determine the data model class that the data will be hydrated into.
In the following example, when value of table field some_discriminator_field is value a,
the table row will be hydrated to SomeDataModelClass::class.
Otherwise, when the value is value b, the table row will be hydrated using SomeOtherDataModelClass::class class.
| Vendor/ModuleName/DataModel/Example.php | |
|---|---|
Default values
To set default data, override the method getDefaultData and return an array of field names mapped to a value.
This data will be set on the data model upon instantiation.
| Vendor/ModuleName/DataModel/Example.php | |
|---|---|
Tracking changes
A data model stores a copy of the data which can be used to check which values have changed.
Methods hasOriginalData, getOriginalData can be used to fetch the original data of the model.
Additionally, methods isModified and isDeleted can be used to check if the model was modified or deleted.
These flags can be reset using clearModified or changed using setDeleted.
Duplication
To make a copy of a data model, use the copy method. This creates a new instance using the same data, but
without the primary key field.
Important
This doesn't do a deep copy of any nested data models which might have been added to the model.
Events
All the following events are dispatched using
Amarant\Framework\Database\Event\DataModelEvent object
from Amarant\Framework\Database\Repository\AbstractRepository.
Important
When referring to these events, always use Amarant\Framework\Enum\EventEnum. Do not hardcode event names.
EventEnum::DATA_MODEL_EVENT_NAME_HYDRATE_AFTER — dispatched after the data was fetched from the database and all hydrators were executed.
EventEnum::DATA_MODEL_EVENT_NAME_SAVE_BEFORE , EventEnum::DATA_MODEL_EVENT_NAME_SAVE_AFTER — dispatched before and after all persisters are executed.
EventEnum::DATA_MODEL_EVENT_NAME_COMMIT_AFTER — dispatched after the database transaction commits following a save. Use this instead of SAVE_AFTER when your subscriber must not run if the transaction is rolled back.
EventEnum::DATA_MODEL_EVENT_NAME_DELETE_BEFORE , EventEnum::DATA_MODEL_EVENT_NAME_DELETE_AFTER — dispatched before and after the model was deleted.
EventEnum::DATA_MODEL_EVENT_NAME_DELETE_COMMIT_AFTER — dispatched after the transaction commits following a delete.
EventEnum::DATA_MODEL_EVENT_NAME_DELETE_BY_IDS_BEFORE , EventEnum::DATA_MODEL_EVENT_NAME_DELETE_BY_IDS_AFTER — dispatched before and after deleting rows using primary keys.
EventEnum::DATA_MODEL_EVENT_NAME_QUERY_BUILDER_BUILD_BEFORE — dispatched before every query is built. The QueryBuilder for the current query is available under the query_builder key in the event data bag. Subscribers can add filters, joins, or sort conditions to every query issued for a particular data model. This is the mechanism used by data scope limiting.
Note
If a transaction is retried, for example in case of a deadlock, each event won't be fired more than once.
Table-scoped event variants
Every event above is also dispatched a second time with the table name appended as a suffix. Use EventEnum::getSuffixedEventName() to subscribe only to events for a specific data model.
use Amarant\Framework\Enum\EventEnum;
use Vendor\ModuleName\DataModel\Example;
// fires for every data model
$generalEvent = EventEnum::DATA_MODEL_EVENT_NAME_SAVE_AFTER->value;
// fires only when Example is saved
$tableEvent = EventEnum::getSuffixedEventName(
EventEnum::DATA_MODEL_EVENT_NAME_SAVE_AFTER,
Example::getTable()
);
Prefer the table-scoped variant whenever your subscriber is only relevant for one data model. The general event is for cross-cutting concerns.
Event object
All data model events deliver a Amarant\Framework\Database\Event\DataModelEvent instance. Key methods:
| Method | Available in | Description |
|---|---|---|
getModel(): ?DataModelInterface |
All events | The data model instance. |
getModelClass(): string |
All events | The data model class name. |
getConnection(): ConnectionInterface |
All events | The database connection used for this operation. Useful for performing related queries or inserts within the same transaction. |
isNew(): bool |
Save events | true for an INSERT, false for an UPDATE. |
wasModified(): bool |
Save events | true if any field was changed before save. |
getOldData(): array |
Save / delete events | Snapshot of the model's data before the operation. |
getIds(): ?array |
Delete-by-ids events | The array of primary key values being deleted. |
getPreviousEvent(): ?DataModelEventInterface |
Commit events | Points to the corresponding SAVE_AFTER or DELETE_AFTER event. |
areKeysModified(array $keys, array $keyTypes, ?string $path): bool |
Save events | Returns true if any of the given field keys differ between old and current data. |
getKeysDiff(array $keys, bool $stopOnFirstMatch, array $keyTypes, ?string $path): array |
Save events | Returns an associative diff of changed keys: ['key' => ['old' => …, 'new' => …]]. |
areKeysChangedFromTo(array $values, array $keyTypes, ?string $path): bool |
Save events | Returns true if fields changed from specific old values to specific new values. |
Pausing events
By implementing Amarant\Framework\Contract\Database\DataModel\EventAwareDataModelInterface
on a data model, events can be paused.
If the method isPaused returns true, no events will be fired.
Repositories
The next thing we should do after creating a new data model is to create a repository for it.
Repositories expose a public interface the rest of the application can use to fetch, save and delete data for a particular data model.
Always use a default repository class, configured for a particular data model, and inject it into your repository, unless you have a good reason not to. The default repository takes care of events, hydration, persistence and more.
Creating a repository
First, create an interface for the repository. This will ease up testing, as you can swap to a different implementation. Also, you can make the implementation final.
Next, create the implementation.
Check the annotations to know more.
- We'll inject this default repository in the next step.
Configure the repository and inject the default repository.
- Inject a default repository for the
Exampledata model, using call injection on the repository pool.
You can now inject and use ExampleRepositoryInterface.
Query caching
Repositories support an optional Redis-backed query result cache. Each query is keyed by a SHA-256 hash of its SQL and bound parameters. Cache entries are invalidated automatically by the repository whenever a model is saved or deleted — there is no manual invalidation needed in normal use.
Opting in
By default the cache is disabled for every data model. To enable it, override isCacheable to return true.
| Vendor/ModuleName/DataModel/Example.php | |
|---|---|
Cache invalidation
The repository clears cached results automatically:
- On
save($model)— clears all cached queries that included the saved model's row, identified by primary key. - On
delete($model)— clears all cached queries that included the deleted model's row. - On
deleteByIds($ids),deleteByQuery($qb), andtruncate()— clears all cached queries for the data model, because the exact set of affected rows is unknown.
Limitation: joined queries are not cached
Queries that join other tables are never stored in the cache. The cache cannot determine which joined rows affect a cached entry, so it conservatively skips caching whenever joins are present.
If a data model is marked isCacheable and the query has joins, the query runs against the database as normal — there is no error, just no caching.
Instance caching (experimental)
isInstanceCacheable() is an alternative mode that caches fully hydrated data model instances rather than raw result sets. The data model must be serializable. It is currently slower than standard query caching and is not recommended for general use.
Migrations
Migrations are used to update the database schema.
They can be executed and reverted. Migrations are executed for each application module, in the order they were added in, and each module is migrated in the order of the module dependency chain.
In the case of revert, same logic applies but in reverse order.
Create a migration
Use the Amarant CLI to create a migration. You must specify the module name.
Note
The name parameter is optional. It's used to add a suffix to the migration file name.
We now have a new migration file created.
Let's create a table in the up method and drop it in the down method.
Execute and revert migrations
To execute pending migrations, use:
To revert migrations, use:
This is a partial revert. It will skip reverting all other migrations after this particular one. To revert
all migrations, remove the until parameter.
Note
You can add the --dry-run parameter to skip database queries, allowing you to see which migrations would
be executed or reverted and in which order.
Warning
Doing a full revert, without the until and dry-run parameters effectively removes all defined tables from the database and
truncates the migrations table.
Hydrators
Hydrators are used to normalize data loaded from the database and populate a data model with it. The default hydrator populates only the fields that are defined in a table a data model is bound to.
Multiple hydrators can be used to add or change the data model's data, for example to load some other data and enrich the model with it.
Create a hydrator
Check the annotations to know more.
- Return
trueif this hydrator supports the data model and the source data. Returningfalsewill result inhydratenot being called on this class. - Use the value from source if the tag priority of this hydrator is before the default hydrator.
- Use the value from the data model if the tag priority of this hydrator is after the default hydrator.
- Always clear the modified flag on the data model, because every change on the model results in the modified flag being set to
true.
Tag the hydrator using dependency injection configurator.
- Use priority lower than 500 to run this hydrator after the default one or use priority greater than 500 to run before it.
Persisters
Persisters are used to save data to the database, converting the values to database suitable format, depending on field types, in the process.
The default persister saves only the fields that are defined in a table a data model is bound to.
As with hydrators, multiple persisters can be used, most commonly to save data to related tables.
Create a persister
Check the annotations to know more.
- Return
trueif this persister supports the data model. Returningfalsewill result inpersistnot being called on this class. - Persisters are executed within a database transaction. Primary key value is already present on the data model if this persister is executed after the default one. It can be used to save data to some other, related tables using the connection or a repository that can be injected in the constructor.
Tag the persister using dependency injection configurator.
- Use priority lower than 500 to run this persister after the default one or use priority greater than 500 to run before it.
Querying
Queries are built using Amarant\Framework\Database\Connection\QueryBuilder. Obtain an instance via the injected repository:
QueryBuilder is a fluent builder. Call filter and modifier methods on it, then pass it to a repository query method.
Filtering
The where method sets the initial condition. Each subsequent call appends with AND by default.
- A plain value defaults to
Filter::OPERATOR_EQ. - Operator is specified as a nested array:
[Filter::OPERATOR_* => $value]. andWhereOrappends anAND (… OR …)group.
| Method | SQL equivalent |
|---|---|
where(array $fields) |
Sets the first condition group (WHERE …). |
andWhere(array $fields) |
Appends AND (a AND b AND …). |
andWhereNot(array $fields) |
Appends AND NOT (a AND b AND …). |
andWhereOr(array $fields) |
Appends AND (a OR b OR …). |
andWhereNotOr(array $fields) |
Appends AND NOT (a OR b OR …). |
whereQuery(string $query) |
Sets the first condition as a raw SQL string. |
andWhereQuery(string $query) |
Appends a raw SQL string with AND. |
Filter operators
All operators are constants on Amarant\Framework\Search\Builder\Filter.
| Constant | SQL |
|---|---|
Filter::OPERATOR_EQ |
= value |
Filter::OPERATOR_NEQ |
!= value |
Filter::OPERATOR_IN |
IN (…) — uses @> on array columns |
Filter::OPERATOR_NIN |
NOT IN (…) — uses @> on array columns |
Filter::OPERATOR_INA |
Array field contains all values (@>) |
Filter::OPERATOR_NINA |
Array field does not contain all values |
Filter::OPERATOR_AINA |
Array field overlaps values (&&) |
Filter::OPERATOR_ANINA |
Array field does not overlap values (&&) |
Filter::OPERATOR_INOV |
In nested JSON object values |
Filter::OPERATOR_GT |
> value |
Filter::OPERATOR_GTE |
>= value |
Filter::OPERATOR_LT |
< value |
Filter::OPERATOR_LTE |
<= value |
Filter::OPERATOR_NULL |
IS NULL (true) / IS NOT NULL (false) |
Filter::OPERATOR_RANGE |
BETWEEN low AND high — value is [low, high] |
Filter::OPERATOR_NRANGE |
NOT BETWEEN low AND high |
Filter::OPERATOR_LIKE |
LIKE '%value%' |
Filter::OPERATOR_NLIKE |
NOT LIKE '%value%' |
Filter::OPERATOR_ILIKE |
ILIKE '%value%' (case-insensitive) |
Filter::OPERATOR_NILIKE |
NOT ILIKE '%value%' |
Filter::OPERATOR_TRUE |
IS TRUE |
Filter::OPERATOR_FALSE |
IS FALSE |
Filter::OPERATOR_EXISTS |
EXISTS (subquery) |
Filter::OPERATOR_COALESCE_EMPTY |
COALESCE(field, '') = '' |
Filter::OPERATOR_COALESCE_EMPTY_NOT |
COALESCE(field, '') != '' |
Filter::OPERATOR_JSON_INA |
JSON array field contains value |
Filter::OPERATOR_JSON_NINA |
JSON array field does not contain value |
Filter::OPERATOR_JSONB_ATTRIBUTE_PATH_EXISTS |
jsonb_path_exists(…) |
Sorting, grouping, and limiting
$queryBuilder
->withOrderBy(Example::FIELD_FIELD_A, QueryBuilder::SORT_ASC)
->withOrderBy(Example::FIELD_FIELD_B, QueryBuilder::SORT_DESC)
->withGroupBy(Example::FIELD_FIELD_A)
->withLimit('10 OFFSET 0');
Field prefixing
QueryBuilder::prefix(string $field) prefixes a field with the main table alias (e.field_a). Use it when building raw join conditions or ON clauses to avoid ambiguous column names.
Duplicating a query builder
duplicate() returns a deep copy of the current builder state. Useful for building a base query once and forking it for different result sets.
$base = $this->repository->getQueryBuilder()->where([Example::FIELD_FIELD_A => 'active']);
$all = $base->duplicate()->withOrderBy(Example::FIELD_FIELD_A);
$page = $base->duplicate()->withLimit('10 OFFSET 0');
Repository query methods
All methods below are on Amarant\Framework\Contract\Database\Repository\RepositoryInterface.
| Method | Description |
|---|---|
get(int\|string $id): ?DataModelInterface |
Load a single model by primary key. |
getByQuery(QueryBuilder $qb): Set |
Return all matching models as a Set. |
getOneByQuery(QueryBuilder $qb): ?DataModelInterface |
Return the first matching model or null. |
getCount(QueryBuilder $qb): int |
Return the count of matching rows. |
getIdValues(QueryBuilder $qb): iterable |
Stream primary key values. |
getIdValuesAsArray(QueryBuilder $qb): array |
Return primary key values as an array. |
getColumnValuesByQuery(QueryBuilder $qb, string $column): iterable |
Stream a single column's values. |
getColumnValueByQuery(QueryBuilder $qb, string $column): mixed |
Return a single column value from the first matching row. |
scrollByQuery(QueryBuilder $qb): iterable |
Cursor-based scroll over a large result set without loading it all into memory. |
paginateByQuery(QueryBuilder $qb, int $pageSize, int $page, ?Closure $itemConverter): PaginatorInterface |
Return a paginated result set. |
batchLoadById(array $ids): array |
Load models by an array of primary key values; returns [id => DataModel] map. |
batchLoadByField(string $field, array $ids): array |
Load models grouped by a field value; returns [fieldValue => [DataModel, …]] map. |
save(DataModelInterface $dataModel): ?DataModelInterface |
Persist a model (insert or update). |
delete(DataModelInterface $dataModel): void |
Delete a model. |
deleteByIds(array $ids): void |
Delete rows by primary key values. |
deleteByQuery(QueryBuilder $qb): void |
Delete all rows matching the query. |
truncate(): void |
Truncate the table. |
getByRawQuery(string $sql, array $params): Set |
Execute a raw SQL query and hydrate results. |
getOneByRawQuery(string $sql, array $params): ?DataModelInterface |
Execute a raw SQL query and return the first hydrated model. |
getColumnByRawQuery(string $sql, array $params, string $column): iterable |
Execute a raw SQL query and stream a single column. |
Scrolling large result sets
scrollByQuery returns a lazy iterable. Use it when processing tables that are too large to load entirely into memory.
Batch loading
Use batchLoadById and batchLoadByField to avoid N+1 query patterns inside lazy-loading callbacks. Collect all IDs first, then load in a single query.
Transactions
Amarant\Framework\Contract\Database\Connection\ConnectionInterface exposes the transaction API. Inject the connection directly (or retrieve it from ConnectionPool) when you need explicit transaction control.
Automatic retry with transactional
The recommended approach for write operations is transactional, which handles begin/commit/rollback and automatically retries on deadlocks.
- Maximum retry attempts on deadlock. Default is
5.
Manual transaction control
Commit events
queueCommitEvents schedules events to be dispatched only after the outermost transaction commits. This prevents sending notifications or invalidating caches when a transaction is rolled back.
Joins
Joins are added to a QueryBuilder via withJoin or withJoinOnce. The join type is controlled by constants on Amarant\Framework\Database\Join.
['alias' => 'table_name']— exactly one entry.QueryBuilder::prefixadds the main table alias to avoid ambiguity.- Columns to include from the joined table in the SELECT.
Join::JOIN_TYPE_LEFT,JOIN_TYPE_RIGHT,JOIN_TYPE_INNER, orJOIN_TYPE_FULL.
withJoinOnce is identical but skips adding the join if the same alias was already added. Use it in shared query-builder helpers that may be called multiple times.
Upserts
ConnectionInterface::upsert inserts a row or updates it on conflict. Use Amarant\Framework\Database\OnConflict to describe the conflict target and action.
- The conflict target column (unique constraint column).
OnConflict::ACTION_UPDATEto update,OnConflict::ACTION_NOTHINGto silently skip on conflict.- Which columns to update when a conflict is detected. Ignored when
ACTION_NOTHINGis used.
Raw values
Amarant\Framework\Database\RawValue wraps a value to prevent it from being quoted or parameterized. Use it when passing a literal SQL expression as a column value.
RawValue::fromValues(array $values) is a convenience constructor that wraps each element of an array in a RawValue.