Nextras Orm

repository·main·Indexed 43 days ago

https://github.com/nextras/orm

A lightweight, high-performance Object-Relational Mapper (ORM) for PHP focusing on clean object design. It supports MySQL, PostgreSQL, and MS SQL Server, utilizing a three-layer architecture consisting of Entities, Repositories, and Mappers. The library provides advanced collection filtering with support for logical operators, relationship traversing, aggregators, and custom collection functions.

Tokens
31K
Snippets
88
Records
111
Agent score
55%

What's inside Nextras Orm

  1. Overview of Nextras Orm

    main

    Nextras Orm is a lightweight and high-performance Object-Relational Mapper (ORM) designed with a focus on clean object design. It provides an abstraction layer for interacting with relational databases using object-oriented patterns.

    Supported Databases:

    • MySQL
    • PostgreSQL
    • MS SQL Server
  2. What is a Mapper in Nextras Orm

    main

    The Mapper is the final ORM layer responsible for communicating with the storage engine. While Repositories provide a domain-centric API, Mappers are storage-specific and database-specific. All database-specific logic (such as raw SQL or specific query builder usage) should be encapsulated within the Mapper layer.

    Nextras Orm provides two primary predefined mappers:

    • ArrayMapper: Works over PHP arrays. Useful for testing or non-database storage.
    • DbalMapper: Uses the Nextras\[Dbal\] library to interact with relational databases.

    Collection results from an ArrayMapper are returned as an ArrayCollection instance.

  3. Use Nextras\Orm\Extension to hook into Orm setup

    main

    A new Nextras\Orm\Extension abstract class allows third-party packages to hook into the Orm setup process. You can override specific methods to configure the Model, Repository, Mapper, or Entity Metadata.

    Available hooks:

    • configureModel(IModel $model): void
    • configureRepository(IRepository $repository): void
    • configureMapper(IMapper $mapper): void
    • configureEntityMetadata(EntityMetadata $metadata): void
    • configureEntityPropertyMetadata(EntityMetadata $entityMetadata, PropertyMetadata $propertyMetadata, TypeNode $propertyType): void (runs at compile time during metadata parsing).

    Registration via Nette DI: Register extensions through the extensions option in your Nette configuration. Entries can be class names, Nette\DI\Definitions\Statement objects, or @reference strings.

    nextras.orm:
        model: MyApp\Model
        extensions:
            - MyApp\MyExtension
            - @myAlreadyRegisteredExtension
    class MyExtension extends \Nextras\Orm\Extension
    {
        public function configureModel(IModel $model): void { /* ... */ }
        public function configureRepository(IRepository $repository): void { /* ... */ }
        public function configureMapper(IMapper $mapper): void { /* ... */ }
        public function configureEntityMetadata(EntityMetadata $metadata): void { /* ... */ }
        public function configureEntityPropertyMetadata(
            EntityMetadata $entityMetadata,
            PropertyMetadata $propertyMetadata,
            TypeNode $propertyType,
        ): void { /* ... */ }
    }
  4. Important: Runtime vs Compile-time hooks

    main

    When implementing extensions, you must distinguish between the two types of hooks to avoid performance issues or bugs:

    1. Runtime hooks (configureModel, configureRepository, configureMapper): These run every time the respective service is created. Keep the logic inside these methods lightweight and cheap to execute.

    2. Compile-time hooks (configureEntityMetadata, configureEntityPropertyMetadata): These run only during metadata parsing. The results are serialized into the metadata cache and do not run again on subsequent requests. Do not capture request-specific state in these hooks, as they will not be available when the cached metadata is used.

  5. How extensions work in Nextras Orm

    main

    An extension is an entry point for hooking into the Orm setup process. They allow packages to configure models, repositories, mappers, and entity metadata without modifying application code.

    Extensions are used for tasks such as:

    • Registering custom metadata modifiers.
    • Attaching event callbacks to all repositories.
    • Injecting shared dependencies or configuration into mappers.

    Extensions are instantiated once and shared across the metadata parser, the model, and every repository. They can be implemented by extending Nextras\Orm\Extension and overriding specific lifecycle methods.

    use Nextras\Orm\Extension;
    use Nextras\Orm\Model\IModel;
    use Nextras\Orm\Repository\IRepository;
    
    class MyExtension extends Extension
    {
    	public function configureModel(IModel $model): void
    	{
    		$model->onFlush[] = function ($persisted, $removed) {
    			// ...
    		};
    	}
    
    	public function configureRepository(IRepository $repository): void
    	{
    		$repository->onBeforePersist[] = function ($entity) {
    			// ...
    		};
    	}
    }
  6. Understand the three-layer architecture of Nextras Orm

    main

    Nextras Orm follows a layered architecture designed to decouple your domain model (Entities) from the database implementation (Mappers). The architecture consists of three primary layers:

    1. Entities: Data containers that hold state, validate values, and encapsulate logic for relationships and value encapsulation.
    2. Repositories: The management layer for entities. Repositories provide the API for retrieving, filtering, and persisting specific entities.
    3. Mappers: The persistence backend. Mappers handle the actual interaction with the database. Orm uses Nextras\Dbal as the underlying database abstraction layer.

    These layers are orchestrated by a central Model class. For the system to function correctly, every Entity must have a corresponding Repository and Mapper defined.

  7. Implement ArrayExpression for collection functions

    main

    When implementing processArrayExpression, filtering happens in PHP runtime. This is used for ArrayCollections (e.g., unpersisted relationships).

    1. Use ArrayCollectionHelper::getValue($entity, $propertyName, $aggregator) to retrieve the value from the entity, supporting relationship expressions.
    2. Return an ArrayExpressionResult wrapping a boolean (for findBy) or a comparable value (for orderBy).

    Example of a 'Like' function implementation for arrays:

    public function processArrayExpression(
        ArrayCollectionHelper $helper,
        IEntity $entity,
        array $args,
        ?Aggregator $aggregator = null,
    ): ArrayExpressionResult
    {
        // $args is for example ['phone', '+420']
        \assert(\count($args) === 2 && \is_string($args[0]) && \is_string($args[1]));
    
        $valueResult = $helper->getValue($entity, $args[0], $aggregator);
        return new ArrayExpressionResult(
            value: str_starts_with(haystack: $valueResult->value, needle: $args[1]),
        );
    }
    public function processArrayExpression(
        ArrayCollectionHelper $helper,
        IEntity $entity,
        array $args,
        ?Aggregator $aggregator = null,
    ): ArrayExpressionResult
    {
        // $args is for example ['phone', '+420']
        \assert(\count($args) === 2 && \is_string($args[0]) && \is_string($args[1]));
    
        $valueResult = $helper->getValue($entity, $args[0], $aggregator);
        return new ArrayExpressionResult(
            value: str_starts_with(haystack: $valueResult->value, needle: $args[1]),
        );
    }
  8. Filter by traversing relationships

    main

    You can filter a collection by traversing relationships using a traversing expression. This consists of a path delimited by -> (e.g., relation->property).

    This syntax works for HasOne relationships and HasMany relationships (where it selects entries where at least one related entity meets the condition).

    // find all books which were authored by Jon Snow
    $orm->books->findBy(['author->name' => 'Jon Snow']);
    
    // find all books which were not translated by Jon Snow
    $orm->books->findBy(['translator->name!=' => 'Jon Snow']);
  9. Querying STI collections and accessing non-shared properties

    main

    When using STI, collection calls (like findBy) return a mixed result containing all types defined in the inheritance hierarchy.

    Filtering

    • Shared properties: You can filter by properties defined on the abstract base class directly.
    • Non-shared properties: If you need to filter by a property that only exists on a specific child entity, you must prepend the child class name followed by a double colon (::) to the expression path. This ensures the filtering expression is evaluated deterministically against the correct entity type.

    Example

    To find a PublicAddress by a property (maintainer) that is not present on the base Address class, use the PublicAddress:: prefix in the path.

    $orm->addresses->findBy([
    	'type' => Address::TYPE_PUBLIC,
    	'PublicAddress::maintainer->id' => $maintainerId,
    ]);
  10. Implement DbalExpression for collection functions

    main

    When implementing processDbalExpression, use the DbalQueryBuilderHelper to handle property expressions (which may include complex relationships requiring joins).

    1. Use $helper->processExpression($builder, $propertyName, $aggregator) to get a DbalExpressionResult for the target column/property.
    2. Use the append() method on the returned expression to add SQL operators (e.g., LIKE).
    3. For advanced operations, construct a new DbalExpressionResult. Use the %ex modifier to expand the existing expression and pass the original joins to the new instance to avoid losing relationship data.

    Example of a 'Like' function implementation:

    public function processDbalExpression(
        DbalQueryBuilderHelper $helper,
        QueryBuilder $builder,
        array $args,
        Aggregator|null $aggregator = null,
    ) : DbalExpressionResult
    {
        // $args is for example ['phone', '+420']
        \assert(\count($args) === 2 && \is_string($args[0]) && \is_string($args[1]));
    
        $expression = $helper->processExpression($builder, $args[0], $aggregator);
        return $expression->append('LIKE %like_', $args[1]);
    }
    public function processDbalExpression(
        DbalQueryBuilderHelper $helper,
        QueryBuilder $builder,
        array $args,
        Aggregator|null $aggregator = null,
    ) : DbalExpressionResult
    {
        // $args is for example ['phone', '+420']
        \assert(\count($args) === 2 && \is_string($args[0]) && \is_string($args[1]));
    
        $expression = $helper->processExpression($builder, $args[0], $aggregator);
        return $expression->append('LIKE %like_', $args[1]);
    }
  11. Use Entity Embeddables to group properties

    main

    Embeddables allow you to wrap multiple properties into a single, reusable data class that is embedded within an entity. This helps separate concerns and promotes immutable design.

    To create an embeddable:

    1. Inherit from Nextras\Orm\Entity\Embeddable\Embeddable.
    2. Define properties using PHPDoc @property tags.
    3. In the parent Entity, declare the embeddable property using the {embeddable} PHPDoc tag.

    Note on Nullability: If all properties within an embeddable are null, Orm will not instantiate the embeddable object and will instead set the entity's property to null. The nullability of the embeddable and its properties is derived from your PHPDoc types.

    Note on Updating: Embeddables are immutable by design. To change a value within an embeddable, you must create a new instance of the embeddable class and assign it to the entity property.

    /**
     * @property Address|null $address {embeddable}
     */
    class User extends Nextras\Orm\Entity\Entity
    {
    }
    
    /**
     * @property string $street
     * @property string $city
     * @property string|null $state
     * @property string $country
     * @property string $zipCode
     */
    class Address extends Nextras\Orm\Entity\Embeddable
    {
    	public function __construct(string $street, string $city, ?string $state, string $country, string $zipCode)
    	{
    		parent::__construct([
    			'street' => $street,
    			'city' => $city,
    			'state' => $state,
    			'country' => $country,
    			'zipCode' => $zipCode,
    		]);
    	}
    }
    
    // Usage:
    $user = new User();
    $user->address = new Address('Main st.', 'Prague', null, 'Czechia', '10000');
    echo $user->address->street;
  12. Use Property Wrappers

    main

    Property wrappers encapsulate a property's value.

    • IProperty: Used for "has many" relationships. Reading the property returns a wrapper object holding the relationship.
    • IPropertyContainer: Used for "has one" relationships. It fully encapsulates the value. Setting a value proxies it to the wrapper's setInjectedValue() method.

    You can implement custom wrappers by extending classes like ImmutableValuePropertyWrapper and overriding convertToRawValue() and convertFromRawValue() to handle storage conversion (e.g., JSON encoding/decoding).

    /**
     * @property \stdClass $json {wrapper JsonWrapper}
     */
    class Data extends Entity
    {
    }
    
    class JsonWrapper extends ImmutableValuePropertyWrapper
    {
    	public function convertToRawValue($value)
    	{
    		return json_encode($value);
    	}
    
    	public function convertFromRawValue($value)
    	{
    		return json_decode($value);
    	}
    }