Skip to content
fperugini-b2p edited this page Nov 16, 2022 · 7 revisions

Advanced Mapper usage

This page shows all advanced usages of a Mapper class.

Quick list

List all overridable methods used to declare entity mapping and refine behaviors.

  • configure() : Override to configure mapper properties by calling some internal Mapper methods :
    • setReadOnly(bool) - The entity will be marked as read only, so no write operations will be allowed.
    • disableSchemaManager() - Disables structure upgrade using prime:upgrade command.
    • setQuoteIdentifier(bool) - Tells the query compiler to always quote identifiers like column or table names. Useful if a reserved keyword is used as column.
    • setGenerator(GeneratorInterface|class-string<GeneratorInterface>) - Defines a custom primary key generator. By default, a generator will be created based on autoincrement or sequence configuration.
    • setRepositoryClass(class-string<RepositoryInterface>) - Defines the repository class to use for the given entity. It must take as constructor parameters :
      • Mapper instance
      • ServiceLocator instance
      • CacheInterface instance, may be null if disabled
    • setHydrator(MapperHydratorInterface) - Defines a custom entity hydrator.
  • schema(): array : Defines the table used to store entities.
  • sequence(): array : Defines sequence table used to generate the primary key.
  • buildFields(FieldBuilder $builder) : Declares entity properties and database columns mapping.
  • buildIndexes(IndexBuilder $builder) : Declares indexes on database columns.
  • buildRelations(RelationBuilder $builder) : Declares relations with other entities.
  • getDefinedBehaviors(): array : Declares custom behaviors of the entity lifecycle.
  • customConstraints(): array : Declares query filters that will always be applied on queries.
  • filters(): array : Defines custom query filters.
  • scopes(): array : Defines custom query methods.
  • queries(): array : Defines custom query factories or repository methods.
  • customEvents(RepositoryEventsSubscriberInterface $notifier) : Registers listeners to repository events.

Schema and table definition

To declare the used table, you should implement the schema(): array method.

This method returns an associative array with keys :

  • connection (string) required : the connection name to use. The connection must be declared before using ConnectionManager::declareConnection().
  • database (string) : database name to use. Use this option is not recommended. Prefer declare a connection per database instead.
  • table (string) required : Table name to use.
  • tableOptions (array) : An associative array of advanced options for the table declaration. Available options in MySQL :
    • comment - add a comment on the table.
    • engine - storage engine to use like innodb or myisam.
<?php

use Bdf\Prime\Mapper\Mapper;

class UserMapper extends Mapper
{
    /**
     * {@inheritdoc}
     */
    public function schema(): array
    {
        // Use table users from connection DB
        return [
            'connection' => 'DB',
            'table'      => 'users',
        ];
    }
    
    // ...
}

Sequence

Prime handle the use of a sequence table to generate a primary key. The sequence table can be shared between multiple entities to ensure uniqueness of the generated id. A sequence table is a table with a single column and a single row that contains the last generated id. Every time a new entity is inserted, this id is incremented.

To use a sequence table, simply call the FieldBuilder::sequence() method on the primary key field. You can configure the table by overriding the Mapper::sequence() method that returns the same structure as the schema() method, plus the column option to configure the column name. By default, the sequence options are :

  • connection : same as schema()
  • table : table name from schema() suffixed with _seq
  • column : id
<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\FieldBuilder;

class UserMapper extends Mapper
{
    // ...

    /**
     * {@inheritdoc}
     */
    public function sequence(): array
    {
        // Use table "shared_id" with column "uid" as sequence table
        // The used connection is same as table
        return [
            'table' => 'shared_id',
            'column' => 'uid',
        ];
    }
    
    /**
     * {@inheritdoc}
     */
    public function buildFields(FieldBuilder $builder): void
    {
        // Declare that id is the primary key and use a sequence table
        $builder->integer('id')->sequence();
        //...
    }
    
    // ...
}

Declaring fields

To declare fields, you must override the buildFields(FieldBuilder $builder): void method and call the FieldBuilder methods.

Type declarations

To declare a field, you first have to call add() or one of the above methods to define the field type. Those methods take the default value as last parameter. The field name must match with the entity property name. All fields visibilities (i.e. public, protected, private) are supported.

To use a custom type, see Declare a custom type

Method name MySQL type PHP Type Comment
string VARCHAR string A small human readable string (max length : 255).
text TEXT string A long human readable string without length limit.
integer INT int 32 bits integer number.
bigint BIGINT int or string 64 bits integer number.
smallint SMALLINT int 16 bits integer number.
tinyint TINYINT int 8 bits integer number.
double DOUBLE float 64 bits floating point number.
float float Alias of double.
decimal DECIMAL string Fixed point number. Use string on PHP to ensure that the precision will be kept.
boolean TINYINT bool Boolean value. If the platform does not natively support the boolean type, 1 will be used as true and 0 as false.
date DATE DateTimeInterface Date value (i.e. with time). Uses a string type if the platform does not support it natively with format YYYY-MM-DD.
dateTime DATETIME DateTimeInterface Date and time value. Uses a string type if the platform does not natively support it with the YYYY-MM-DD HH:MM:SS format.
dateTimeTz DATETIME DateTimeInterface Date and time value with timezone info. Uses a string type if the platform does not support it natively with format YYYY-MM-DD HH:MM:SS. Note: MySQL does not support this type and will behave as dateTime.
time TIME DateTimeInterface Time value (i.e. without date). Use a string type if the platform does not support it natively with format HH:MM:SS.
timestamp INT DateTimeInterface A unix timestamp stored as a 32 bits integer. Note: this type does not use the MySQL TIMESTAMP type.
binary BINARY string Binary string.
blob BLOB string Long binary string.
guid CHAR(36) string UUID string. Internally uses a fixed string of length 36 if not supported by the platform.
json TEXT mixed JSON string. Parse JSON object to PHP associative array.
simpleArray TEXT string[] CSV string. Elements are separated using commas ,. A comma is added to the beginning and end of the value to allow search using regex or LIKE operators (ex: ,foo,bar,baz,). Empty values are filtered.
object TEXT object Serialized object. Use serialize() and unserialize() functions internally. Be aware with this type: there is no filter mechanism for ensure security (i.e. filter unserialized classes), and database will strongly depends on PHP implementation which decrease maintainability and interoperability.
arrayObject TEXT array Serialized abstract array structure. Works like object() with the same drawbacks. Rather use json().
searchableArray TEXT string[] Same as simpleArray().
arrayOf TEXT array CSV string with typed element which is defined using the second parameter. Internally uses the same structure as simpleArray(), and forwards elements parsing to the type passed as second parameter.
arrayOfInt TEXT int[] CSV string of integers. Same as arrayOf($field, 'int').
arrayOfDouble TEXT float[] CSV string of floats. Same as arrayOf($field, 'double').
arrayOfDateTime TEXT DateTime[] CSV string of DateTime. Same as arrayOf($field, 'datetime').

Field options

Extra options can be set to each field to change its behavior. Those options must be called after the field declaration.

Method name Supported types Comment
autoincrement All int types (integer, bigint...) Marks field as primary, with AUTOINCREMENT value generation. Note: composed PK is not supported by autoincrement.
sequence All int types (integer, bigint...) Marks field as primary, using a sequence table as value generation. Note: composed PK is not supported by autoincrement.
primary All Marks field as primary key. You can mark multiple fields as primary key to create a composed key.
length All string types (string, binary...) Defines field length.
comment All Adds a comment on the column.
alias All Defines database column name.
setDefault All Defines default value. This value must be in PHP and not the normalized one (i.e. for datetime use new DateTime('2010-05-25 15:55') instead of string '2010-05-25 15:55:00').
precision decimal Defines decimal precision, with first parameter as total number of digits, and second as number of digits after decimal point.
nillable All Marks the field as nullable.
unsigned All numeric types (integer, double...) Stores numbers as unsigned.
unique All Adds unique constraint and index to field. When passing a string as parameter you can define the index name, and declare a compound index if same index is declared on multiple fields.
fixed All string types (string, binary...) Marks the field as fixed length string. So will use CHAR instead of VARCHAR or BINARY instead of VARBINARY.
phpClass Date and time types (dateTime, date...) Defines the used DateTimeInterface class name.
timezone Date and time types (dateTime, date...) Defines the timezone name used for parsing the date from database. Note: the date will not be set to this given timezone on save from PHP to database.

Embedded types

Complex tree types can be defined to unflat row data, group some columns and get a clearer entity object. Two types of embedded structure are available :

  • embedded : Simple embedded object. Takes the embedded class as second parameter, and a closure as last parameter to configure embedded fields.
  • polymorph : Embedded object that allows multiple classes, according to a given field value. The discriminator field must be marked using ->discriminator().

Note: Embedded object must not be null, so the entity class must implement InitializableInterface and instantiate embedded fields objects.

Examples:

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\FieldBuilder;
use Bdf\Prime\Entity\Model;
use Bdf\Prime\Entity\InitializableInterface;

class Delivery extends Model implements InitializableInterface
{
    public ?string $id = null;
    // ...
    public Address $from;
    public Address $to;
    // ...

    public function __construct(array $data = []) 
    {
        $this->import($data);
        $this->initialize();
    }
    
    // Embedded objects must not be null, so implements `initialize` to ensure that embedded objects are initialized
    public function initialize(): void
    {
         $this->from = new Address();
         $this->to = new Address();
    }
}

class DeliveryMapper extends Mapper
{
    // ...

    public function buildFields(FieldBuilder $builder): void
    {
        // Group address fields into an object
        // Note: alias() is used to define flatten column name mapping
        $builder
            // ...
            ->embedded('from', Address::class, function (FieldBuilder $builder) {
                $builder
                    ->string('address')->alias('from_address')
                    ->string('city')->alias('from_city')
                    ->string('zipcode')->alias('from_zipcode')
                    ->string('countryCode')->length(3)->alias('from_countryCode')
                ;
            })
            ->embedded('to', Address::class, function (FieldBuilder $builder) {
                $builder
                    ->string('address')->alias('to_address')
                    ->string('city')->alias('to_city')
                    ->string('zipcode')->alias('to_zipcode')
                    ->string('countryCode')->length(3)->alias('to_countryCode')
                ;
            })
            // ...
        ;
    }

    // ...
}
<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\PolymorphBuilder;

class StringOption
{
    public string $type = 'string';
    public ?string $value = null;
}

class FooOption
{
    public string $type = 'foo';
    public array $value = [];
    
    public function bar()
    {
        return $this->value['bar'] ?? null;
    }
}

class OptionMapper extends Mapper
{
    // ...

    public function buildFields(FieldBuilder $builder): void
    {
        $builder
            // ...
            ->polymorph(
                'value', 
                [
                    // Define discriminator to class mapping
                    'string' => StringOption::class,
                    'foo' => FooOption::class,
                ], 
                function (PolymorphBuilder $builder) {
                    // All classes must have same properties
                    $builder
                        ->string('type')->alias('value_type')->discriminator() // type is used as discriminator for choose corresponding class
                        ->json('value')->alias('value')
                    ;
                }
            )
            // ...
        ;
    }

    // ...
}

Examples

Compound primary key :

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\FieldBuilder;

class UserOptionMapper extends Mapper
{
    // ...

    public function buildFields(FieldBuilder $builder): void
    {
        // Primary key of the table is (userId, option), so for each user, it can have only one of each option
        $builder
            ->bigint('userId')->primary()
            ->string('option')->primary()
            ->json('value')
        ;
    }

    // ...
}

Fine tune datetime field :

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\FieldBuilder;

class UserMapper extends Mapper
{
    // ...

    public function buildFields(FieldBuilder $builder): void
    {
        $builder
            ->bigint('id')->primary()
            // ...
            // DateTimeImmutable will be used instead of DateTime, and force use UTC timezone
            ->dateTime('registeredAt')->phpClass(DateTimeImmutable::class)->timezone('UTC')
        ;
    }

    // ...
}

Compound unique index :

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\FieldBuilder;

class AvatarMapper extends Mapper
{
    // ...

    public function buildFields(FieldBuilder $builder): void
    {
        // Make avatar for a given uploader, which can have multiple types
        $builder
            ->bigint('id')->autoincrement()
            ->text('filepath')
            ->bigint('uploader')->unique('unq_uploaded')
            ->string('uploaderType')->unique('unq_uploaded')
        ;
    }

    // ...
}

Declaring indexes

Indexes can be declared by overriding the buildIndexes(IndexBuilder $builder): void method and calling the IndexBuilder methods. The index name can be manually set, or let Prime generate it.

Note: the builder uses the entity property name as field name, but also supports database identifier. So you can declare an index on an undeclared field.

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\IndexBuilder;

class UserMapper extends Mapper
{
    // ...

    public function buildIndexes(IndexBuilder $builder): void
    {
        $builder
            ->add()->on('login')->unique() // Add unique index on "login" field.
            ->add('idx_pseudo')->on('pseudo', ['length' => 12]) // Index only 12 firsts characters
            ->add()->on(['registeredAt', 'login']) // Compound index
            ->add()->on('registeredAt')->on('login') // Same as above
            ->add()->on('bio')->flag('fulltext') // Define "bio" field as fulltext index
        ;
    }

    // ...
}

Declaring relations

Relations between entities are declared by overriding the buildRelations(RelationBuilder $builder): void method and using RelationBuilder.

To declare a relation you first need to call RelationBuilder::on(string $name) to define the relations name. Related entities can directly be stored on the current entity, in this case you need to declare a property named like the relation (don't forget to make it nullable). A relation can also be marked as "detached", if there is no property to store it, calling RelationBuilder::detached().

Note: a relation can be defined on en embedded field. This can be useful to ensure that the foreign key is always synchronized with the related entity. You just need to set the related entity to ensure that the foreign key will be updated.

Once the relation name is defined, you can set the relation type and configure it using the builder's methods. You can see below a small recap of the relations types. See Advanced relations usage for more information.

Method name Description Opposite Example
belongsTo Relation with single entity, and foreign key is stored on the current entity. hasOne or hasMany $builder->on('admin')->belongTo(User::class, 'admin.id')
hasOne Relation with single entity when foreign key is the primary key. belongsTo $builder->on('credentials')->hasOne(Credentials::class . '::userId')
hasMany Relation with multiple entities when foreign key is the primary key. belongsTo $builder->on('administrated')->hasMany(Group::class . '::admin.id')
belongsToMany Many to many relation, using a join table. $builder->on('groups')->belongsToMany(Group::class)->through(UserGroup::class, 'userId', 'groupId')
morphTo Same as belongsTo but supporting multiple entity type, depending on value of a discriminator field. morphOne or morphMany $builder->on('uploader')->morphTo('uploaderId', 'uploaderType', ['admin' => AdminUser::class, 'user' => WebUser::class])
morphOne Same as hasOne with additional constrain on the discriminator field to match with morphTo mapping. morphTo $builder->on('credentials')->morphOne(PublicKeyCrendentials::class . '::userId', 'credentialsType=pubkey')
morphMany Same as hasMany with additional constrain on the discriminator field to match with morphTo mapping. morphTo $builder->on('uploads')->morphMany(UploadedFile::class . '::uploaderId', 'uploaderType=user')
inherit Declare a relation which will be configured on sub-class. N/A $builder->on('target')->inherit('targetId')
custom Declare a relation using a custom implementation. N/A $builder->on('target')->custom(MyRelationType::class, ['keys' => ['target.foo', 'target.bar']])

Relation options:

  • through : Defines the join table. Only compatible with belongsToMany
  • entity : Defines the relation entity. Useful for custom relation
  • option : Defines the custom relation option
  • morph : Defines the discriminator field and the discriminator value constraint
  • constraints : Defines filters that will always be applied on the relation query or join
  • detached : Marks the relation as detached
  • saveStrategy : Strategy to use when EntityRelation::saveAll() is called
    • With Relation::SAVE_STRATEGY_REPLACE all previous relations will be deleted, replaced with the new one (default value)
    • With Relation::SAVE_STRATEGY_ADD previous relations will not be purged, and the new one will be added
  • wrapAs : Defines the collection class to use instead of array to store loaded entities
  • mode : When used with the value RelationBuilder::MODE_EAGER, the relation will always be loaded unless Query::without() is called

Example:

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\FieldBuilder;
use Bdf\Prime\Relations\Builder\RelationBuilder;

class GroupMapper extends Mapper
{
    // ...

    public function buildFields(FieldBuilder $builder): void
    {
        $builder
            ->integer('id')->autoincrement()
            ->embedded('admin', User::class, function (FieldBuilder $builder) {
                $builder->integer('id')->alias('admin_id');
            })
            ->string('name')
        ;
    }
    
    public function buildRelations(RelationBuilder $builder): void
    {
        $builder
            // Use an embedded entity as relation
            // "admin" field will be loaded by using "admin_id" column as foreign key
            ->on('admin')->belongsTo(User::class, 'admin.id')
            
            // Many-to-many relation, using a join table
            // Note: UserGroup entity and UserGroupMapper should be declared
            // Use detached to not store user list into group entity
            ->on('users')
                ->belongsToMany(User::class)
                ->through(UserGroup::class, 'groupId', 'userId')
                ->detached()
        ;
    }

    // ...
}

$group = Group::findById($gid);

// Load the admin relation
// Will execute query like "SELECT * FROM users WHERE id = {$group->admin()->id()} LIMIT 1"
$group->load('admin'); 
$group->admin(); // Admin entity retrieved from database

// Use a detached relation. load() is not allowed
// Will execute query like "SELECT * FROM users INNER JOIN user_group ON user_group.user_id = users.id WHERE user_group.group_id = {$group->id()}"
$group->relation('users')->all(); // Get all users of the group

// Relation can be used as query filter
// The query builder will automatically add required join clause to query
// Will execute query like :
// SELECT * FROM groups t1
// INNER JOIN users t2 ON t1.admin_id = t2.id
// INNER JOIN user_group t2 ON t2.group_id = t1.id
// INNER JOIN users t3 ON t3.id = t2.user_id
// WHERE t1.name = 'Robert' AND t3.name = 'Jean'
Group::builder()
    ->where('admin.name', 'Robert')
    ->where('users.name', 'Jean')
    ->all();

Behaviors

Prime Mappers allow to register behaviors. They allow to change the repository behavior by :

  • Adding or modifying fields
  • Registering entity lifecycle events listeners
  • Adding constraints

To define behaviors, you need to override the getDefinedBehaviors(): array method and return the list of requested behaviors.

Available behaviors

The soft deleteable behavior allows you to “soft delete” objects, filtering them at SELECT time by marking them with a timestamp, but not explicitly removing them from the database.

By default, the deletedAt field will be added with the dateTime type. To search deleted entities, use EntityRepository::withoutConstraints() before creating the query.

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Builder\FieldBuilder;
use Bdf\Prime\Behaviors\SoftDeleteable;

class UserMapper extends Mapper
{
    // ...

    public function getDefinedBehaviors(): array
    {
        return [new SoftDeleteable()]; // Use "deletedAt" field
        return [new SoftDeleteable('removedAt', 'bigint')]; // Use "removedAt" field as bigint (saved as timestamp)
    }

    // ...
}

$user = User::findById(123);
$user->delete(); // Do not delete user, simply set deleteAt to new DateTime()

User::refresh($user) === null; // User is not accessible
User::withoutConstraints()->refresh($user) === null; // You can access to entity by disabling constraints
User::withoutConstraints()->delete($user); // You can also skip behavior using the same method

The timestampable behavior allows you to keep track of the date of creation and the date of last update of your model objects.

Note: updatedAt field will not be updated on creation.

By default, it adds fields createdAt and updatedAt with dateTime type.

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Behaviors\Timestampable;

class UserMapper extends Mapper
{
    // ...

    public function getDefinedBehaviors(): array
    {
        return [new Timestampable()];
        return [new Timestampable(createdAt: false)]; // Disable createdAt field, so it will only save update time
        return [new Timestampable(createdAt: 'creation', updatedAt: 'update')]; // Define custom field name
    }

    // ...
}

$user = new User(...);
$user->insert();

$user->createdAt(); // = new DateTime('now')
$user->updatedAt(); // = null

$user->setRoles([1, 5])->save();
$user->updatedAt(); // = new DateTime('now')

The blameable behavior automates the update of the username or user reference fields on the entities or documents. It works similar to timestampable behavior. It simply inserts the current user id (or name) into the created_by and updated_by fields. In this way, every time a model gets created, updated or deleted, you can see who did it (or who to blame for that).

By default, it adds createdBy and updatedBy fields with string type.

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Behaviors\Blameable;

class FooMapper extends Mapper
{
    // ...

    public function getDefinedBehaviors(): array
    {
        // Define user resolver. Use container stored into ServiceLocator.
        return [new Blameable(fn () => $this->serviceLocator->di()->get('session')->user()->id())];
    }

    // ...
}

$user = User::findById(123);
$di->get('session')->setUser($user);

$foo = new Foo(...);

$foo->insert();
$foo->createdBy(); // = 123
$foo->updatedBy(); // = null

$foo->setBar('aaa')->save();
$foo->updatedBy(); // = 123

Custom behavior

To create a custom behavior, simply implement Bdf\Prime\Behaviors\BehaviorInterface.

This interface has 3 methods :

  • changeSchema(FieldBuilder $builder): void for adding or modifying fields
  • subscribe(RepositoryEventsSubscriberInterface $notifier): void for registering listeners to entity lifecycle events
  • constraints(): array for adding search constraints

For example, we're going to create a behavior that filters entities by country, so we need to :

  • Resolve the country code (= add a closure at constructor parameter)
  • Save the country code on the entity (= add a country code field on the entity and listen for insert)
  • Filter entities by country code (= add a constraint)
  • Disallow modifying an entity from an invalid country (= listen to the update or delete operation to throw an exception if the country is invalid)
<?php

use Bdf\Prime\Behaviors\BehaviorInterface;
use Bdf\Prime\Mapper\Builder\FieldBuilder;
use Bdf\Prime\Repository\RepositoryEventsSubscriberInterface;
use Bdf\Prime\Repository\EntityRepository;
use Bdf\Prime\Repository\RepositoryInterface;

class CountryFilterBehavior implements BehaviorInterface
{
    /**
     * Resolve current country code 
     * 
     * @var Closure():string  
     */
    private Closure $countryResolver;
    
    public function __construct(Closure $countryResolver)
    {
        $this->countryResolver = $countryResolver;
    }
    
    public function changeSchema(FieldBuilder $builder): void
    {
        // Field "countryCode" is already defined
        if (isset($builder['countryCode'])) {
            return;
        }

        // Define "countryCode" field, with a fixed length of 2
        $builder->string('countryCode')->alias('country_code')->length(2)->fixed();
    }
    
    public function constraints() : array
    {
        // Add constraint on country code field
        return ['countryCode' => ($this->countryResolver)()];
    }
    
    public function subscribe(RepositoryEventsSubscriberInterface $notifier) : void
    {
        // Set the country code before insertion
        $notifier->inserting([$this, 'onInsert']);
        $notifier->inserting($this->onInsert(...)); // Since PHP 8.1
        
        // Disallow write operation if country do not match
        $notifier->deleting([$this, 'onWrite']);
        $notifier->updating([$this, 'onWrite']);
    }

    public function onInsert($entity, RepositoryInterface $repository): void
    {
        $repository->mapper()->hydrateOne($entity, 'countryCode', ($this->countryResolver)());
    }

    public function onWrite($entity, EntityRepository $repository): void
    {
        // Constraints are disable, so ignore this behavior
        if ($repository->isWithoutConstraints()) {
            return;
        }

        $actualCountry = $repository->mapper()->extractOne($entity, 'countryCode');
        
        // Check country code value
        if ($actualCountry !== ($this->countryResolver)()) {
            throw new RuntimeException('Not allowed : country do not match');
        }
    }
}

Now, it can be registered on the mapper as any other behavior :

<?php

use Bdf\Prime\Mapper\Mapper;

class FooMapper extends Mapper
{
    // ...

    public function getDefinedBehaviors(): array
    {
        return [new CountryFilterBehavior(fn () => $this->serviceLocator->di()->get('session')->user()->countryCode())];
    }

    // ...
}

Adding constraints

To apply query filters by default, you can override the customConstraints(): array method. Those constraints will be added to the query by calling Query::where().

<?php

use Bdf\Prime\Mapper\Mapper;

class UserMapper extends Mapper
{
    // ...

    public function customConstraints(): array
    {
        // Only access to enabled users
        // So it's equivalent of calling User::builder()->where(['enabled' => true]) on every query
        return ['enabled' => true]; 
    }

    // ...
}

$user = new User(...);
$user->setEnabled(true);

$user->insert();
$user == User::refresh($user); // User is enabled

$user->setEnabled(false)->save();
null === User::refresh($user); // User is not enabled, so it's filtered

Custom filters

You can declare custom filters by overriding the filters(): array method. Once declared, it can be used on all queries. Custom filters can be used to refactor complex filters into a single filter, or hide database structure / data format for searching entities.

A filter is declared using the array key as name, and a closure with the query as the first parameter and the filter value as the second one. This closure must return nothing. Prototype: Closure(Query $query, mixed $value): void

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Query\Query;
use Bdf\Prime\Query\Expression\Like;

class UserMapper extends Mapper
{
    // ...

    public function filters(): array
    {
        return [
            // Filter for hide DB format
            'hasRole' => function (Query $query, int $role): void {
                $query->where('roles', (new Like($role))->searchableArray());
            },
            // More complex filter
            'hasOneRole' => function (Query $query, array $roles): void {
                // Wrap OR statements into a nested statement to ensure that OR will not be mixed with other filters
                $query->where(function (Query $query) use($roles) {
                    foreach ($roles as $role) {
                        $query->orWhere('roles', (new Like($role))->searchableArray());
                    }
                });
            },
        ]; 
    }

    // ...
}

User::where('hasRole', 3)->all(); // All users will have role 3
User::where('hasOneRole', [3, 4])->all(); // All users will have role 3 or 4

Scopes

A scope is an system extension to declare query and repository methods. To declare scopes, simply override scopes(): array.

A scope is declared using the array key as name, and a closure with the query as the first parameter, and arbitrary extra parameters, and can return anything.

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Query\Query;
use Bdf\Prime\Query\Expression\Like;
use Bdf\Prime\Query\Pagination\Walker;

class UserMapper extends Mapper
{
    // ...

    public function scopes(): array
    {
        return [
            // Simple query execution method
            'getUsersByRole' => function (Query $query): array {
                $usersByRole = [];

                foreach ($query as $user) {
                    foreach ($user->getRoles() as $role) {
                        $usersByRole[$role][] = $user;
                    }
                }

                return $usersByRole;
            },

            // Scope with argument
            'search' => function (Query $query, string $terms): Walker {
                $query->where(function (Query $query) use($terms) {
                    foreach (explode(' ', $terms) as $term) {
                        $fieldAndTerm = explode(':', $term);
                        
                        if (count($fieldAndTerm) === 1 || !in_array($fieldAndTerm[0], ['bio', 'email', 'pseudo', 'id'])) {
                            $query->where(function (Query $query) use($term) {
                                $query
                                    ->where('pseudo', (new Like($term))->enclose())
                                    ->orWhere('bio', (new Like($term))->enclose())
                                ;
                            });
                        } else {
                            $query->where($fieldAndTerm[0], (new Like($fieldAndTerm[1]))->enclose());
                        }
                    }
                });

                return $query->walk();
            }
        ];
    }

    // ...
}

User::getUsersByRole(); // Indexing all users
User::where('pseudo', ':like', 'foo%')->getUsersByRole(); // Apply a filter before execute the query
User::search('foo bar email:test@test.fr'); // Call a scope with an argument

Queries

You can also define a custom query factory, or repository methods by overriding the public function queries(): array method. It works like scopes, but takes the repository as the first parameter instead of the query, and cannot be called from a query instance. This method allow you to use optimised query, or any custom query class instead of Bdf\Prime\Query\Query.

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Query\Query;
use Bdf\Prime\Query\Expression\Like;
use Bdf\Prime\Query\Pagination\Walker;
use Bdf\Prime\Repository\RepositoryInterface;
use Bdf\Prime\Query\Custom\BulkInsert\BulkInsertQuery;

class UserMapper extends Mapper
{
    // ...

    public function queries(): array
    {
        return [
            // Create an optimised query and execute it
            'findByLogin' => function (RepositoryInterface $repository, string $login): ?User {
                return $repository->queries()->keyValue('login', $login)->first();
            },
            
            // Use as query factory
            'bulkInsertQuery' => function (RepositoryInterface $repository): BulkInsertQuery {
                return $repository->queries()
                    ->make(BulkInsertQuery::class)
                    ->columns(array_keys($this->metadata()->attributes))
                    ->bulk()
                ;
            }
        ];
    }

    // ...
}

User::findByLogin('bob'); // Execute the optimised query

/** @var BulkInsertQuery $bulkInsert */
$bulkInsert = User::bulkInsertQuery(); // Use query factory
$bulkInsert->values(...);
$bulkInsert->values(...);
$bulkInsert->values(...);
$bulkInsert->execute();

Registering listeners

You can also register listeners to entity lifecycle events without creating a behavior by overriding customEvents(RepositoryEventsSubscriberInterface $notifier): void. The $notifier parameter is a builder for new events listeners registration. All the listeners take the entity instance as the first parameter, and the repository as the second one.

Method name Event description Parameters (excluding entity and repository) Return value behavior
loaded The entity has been retrieved from database. This event is triggered on every query result. None Skip following event on false without extra effects.
saving Method save() has been call on the entity. Note: other write events will also be triggered. ?bool $isNew : true if the PK is not set, and an insert will be perform; false if the PK is set, and an update will be perform; null when cannot resolve entity state, and a replace will be performed. Return false to cancel save (no write operation will be perform).
saved The entity has been successfully saved. ?bool $isNew and int $count. The $isNew parameter is same as previous listener. $count is the number of affected rows by write operation (0 on no-op, 1 on successful insert or update, 2 on successful replace. Skip following event on false without extra effects.
inserting The entity will be inserted. None Return false to cancel insert.
inserted The entity has been successfully inserted. int $count : the number of affected rows. Should be 1 if successful. Skip following event on false without extra effects.
updating The entity will be updated. Updated fields can be changed using the 3rd listener parameter. ArrayObject $fields : in-out array of fields to update. Return false to cancel update.
updated The entity has been successfully updated. int $count : the number of affected rows. Should be 1 if successful. Skip following event on false without extra effects.
deleting The entity will be deleted. None Return false to cancel delete.
deleted The entity has been successfully deleted. int $count : the number of affected rows. Should be 1 if successful. Skip following event on false without extra effects.

Example:

<?php

use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Repository\RepositoryEventsSubscriberInterface;

class UserMapper extends Mapper
{
    // ...

    public function customEvents(RepositoryEventsSubscriberInterface $notifier): void
    {
         $setHash = function ($entity) {
            $entity->setHash(md5(serialize($entity)));
         };

         // Compute entity hash before write operations
         $notifier->inserting($setHash);
         $notifier->updating(function ($entity, $repository, ArrayObject $attributes) use($setHash) {
            $setHash($entity);
            
            if ($attributes !== null) {
                $attributes[] = 'hash';
            }
         });
    }

    // ...
}

Clone this wiki locally