Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/config-nette.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ services:
nextras.orm.dependencyProvider: MyApp\DependencyProvider
```

To hook into the model, repository, mapper, or entity metadata setup, register an [extension](extensions) through the `extensions` option:

```neon
nextras.orm:
extensions:
- MyApp\MyExtension
- @myAlreadyRegisteredExtension
```

Orm sets up all internal services as autowired. This may be toggled by the `autowiredInternalServices` option. This may be useful, especially when the Orm extension is used multiple times. The `connection` option allows specifying the related connection instance.

```neon
Expand Down
89 changes: 89 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
## Extensions

An extension is an entry point for hooking into Orm's setup. It lets bundled or third-party packages consistently configure the model, repositories, mappers, and entity metadata without patching your application code. Typical use cases:

- registering custom metadata modifiers or reacting to parsed entity metadata (e.g. adding behavior based on a custom property modifier),
- attaching event callbacks to every repository,
- injecting shared dependencies or configuration into mappers.

To implement an extension, extend the `Nextras\Orm\Extension` abstract class and override only the methods you need. All methods have an empty default implementation.

```php
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) {
// ...
};
}

// e.g. assign a custom property wrapper to every property of a given type
public function configureEntityPropertyMetadata($entityMetadata, $propertyMetadata, $propertyType): void
{
if ($propertyMetadata->wrapper !== null) return;
foreach ($propertyMetadata->types as $type => $_) {
if ($type === LocalDate::class || is_subclass_of($type, LocalDate::class)) {
$propertyMetadata->wrapper = LocalDatePropertyWrapper::class;
}
}
}
}
```

#### Hooks

| Method | Runs |
|-------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| `configureModel(IModel $model)` | Once, when the model is instantiated. |
| `configureRepository(IRepository $repository)` | Every time a repository is instantiated at runtime. |
| `configureMapper(IMapper $mapper)` | Every time a mapper is instantiated at runtime. |
| `configureEntityMetadata(EntityMetadata $metadata)` | At compile time, when entity metadata is parsed (before it is cached). |
| `configureEntityPropertyMetadata(EntityMetadata $entityMetadata, PropertyMetadata $propertyMetadata, TypeNode $propertyType)` | At compile time, per property, when entity metadata is parsed (before it is cached). |

The runtime hooks (`configureModel`, `configureRepository`, `configureMapper`) run each time the respective service is created, so keep them cheap. The compile-time hooks (`configureEntityMetadata`, `configureEntityPropertyMetadata`) run only while metadata is parsed and their result is serialized into the metadata cache — they do **not** run again on subsequent requests served from the cache. Because of that, do not capture request-specific state in them.

#### Registration with Nette DI

Register extensions through the `extensions` option of the [Nette DI](config-nette) `OrmExtension`. Each entry may be:

- a class name — a new service is registered and instantiated once,
- a `Nette\DI\Definitions\Statement` — a factory for a new service,
- a `@reference` — an already registered service is reused.

```neon
nextras.orm:
model: MyApp\Model
extensions:
- MyApp\MyExtension
- MyApp\ConfigurableExtension(%myOption%)
- @myAlreadyRegisteredExtension
```

Each extension is instantiated once and shared across the metadata parser, the model, and every repository.

#### Registration without Nette DI

When bootstrapping the model manually via `Nextras\Orm\Model\SimpleModelFactory`, pass the extension instances as the last constructor argument:

```php
use Nextras\Orm\Model\SimpleModelFactory;

$factory = new SimpleModelFactory(
$cache,
$repositories,
extensions: [new MyApp\MyExtension()],
);
$model = $factory->create();
```
1 change: 1 addition & 0 deletions docs/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [Mapper](mapper)
- [Conventions](conventions)
- [Events](events)
- [Extensions](extensions)

###### Tutorials:

Expand Down
63 changes: 63 additions & 0 deletions docs/migrate_6.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,66 @@
- `Nextras\Orm\Entity\ImmutableValuePropertyWrapper` already implements this: it keeps the raw value, converts it lazily via `convertFromRawValue()` on read, and validates nullability centrally. Your `convertFromRawValue()` is free to validate the (converted) value as before — it is simply invoked lazily now.

As a consequence, an invalid raw value now surfaces its exception when the value is **read** rather than when it is **set** (i.e. during hydration / `setRawValue()`).

- **`Model` constructor changed & `Model::getConfiguration()` removed** - the repository configuration array is gone. The model now delegates all repository lookups to its `IRepositoryLoader`.

```php
// before
new Model($configuration, $repositoryLoader, $metadataStorage);
Model::getConfiguration($repositories); // removed

// after
new Model($repositoryLoader, $metadataStorage);
```

This affects you only if you instantiate `Model` manually (i.e. not via the Nette DI extension or `SimpleModelFactory`). The entity-to-repository and name-to-repository maps are now owned by the loader (see below).

- **`IRepositoryLoader` interface reworked** - if you maintain a custom repository loader, update it to the new contract:
- `getRepository()` now returns `IRepository|null` (previously non-nullable). The `Model` throws when it receives `null`.
- `isCreated()` was **removed**; implement `getInitializedRepositories(): list<IRepository<*>>` instead (returns the already-instantiated repositories, used by `flush()` / `clear()` / `refreshAll()`).
- Added `hasRepositoryByName(string $name): bool` and `getRepositoryByName(string $name): IRepository|null`.
- Added `getRepositoryClassNameForEntity(string $entityClassName): ?string` - resolves the repository managing a given entity class (previously the `Model` held this map itself).

- **Removed `Nextras\Orm\Bridges\NetteDI\RepositoryLoader`** - replaced by `Nextras\Orm\Bridges\NetteDI\DiRepositoryLoader`, which implements the new `IRepositoryLoader` contract and reads repositories lazily from the Nette DI container.

- **`IRepositoryFinder` interface reworked** - if you implement a custom repository finder for the Nette DI bridge:
- Constructor arguments were reordered to `__construct(ContainerBuilder $builder, OrmExtension $extension, string $modelClass)`.
- `loadConfiguration(): ?array` was replaced by `registerRepositories(): void` (register your repository service definitions here).
- `beforeCompile(): ?array` was replaced by `resolveRepositories(): list<DiRepositoryEntry>` (return the resolved repository service definitions; the `OrmExtension` wires them to the model). See the new `Nextras\Orm\Bridges\NetteDI\DiRepositoryEntry` value object.

- **`MetadataParserFactory` constructor gained `$extensions`** - it now accepts `list<Nextras\Orm\Extension>` as its first constructor argument and forwards them to the `MetadataParser`. If you register a custom `nextras.orm.metadataParserFactory` service or construct the factory manually, account for the new argument.

- **`SimpleModelFactory` / `SimpleRepositoryLoader` signatures changed** (relevant for non-Nette, manual model bootstrapping):
- `SimpleModelFactory::__construct()` gained a trailing `array $extensions = []` parameter (`list<Extension>`).
- `SimpleRepositoryLoader::__construct()` gained a second `array $entityClassNameToClassNameMap = []` parameter (map of entity class name → managing repository class name), needed for `getRepositoryClassNameForEntity()`.

### New Features

- **`Nextras\Orm\Extension` entry point** - a new abstract class that lets bundled or third-party packages hook into the Orm setup. Override any of the methods you need and register the extension:

```php
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 { /* ... */ }
}
```

With the Nette DI bridge, register extensions through the `extensions` option. Each entry may be a class name, a `Nette\DI\Definitions\Statement`, or a `@reference` to an already registered service:

```neon
nextras.orm:
model: MyApp\Model
extensions:
- MyApp\MyExtension
- @myAlreadyRegisteredExtension
```

The `configureModel`/`configureRepository`/`configureMapper` hooks run when the respective service is instantiated; the `configureEntityMetadata`/`configureEntityPropertyMetadata` hooks run at compile time while entity metadata is parsed (before it is cached).
102 changes: 38 additions & 64 deletions src/Bridges/NetteDI/DIRepositoryFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,104 +4,78 @@


use Nette\DI\ContainerBuilder;
use Nette\DI\Definitions\Definition;
use Nette\DI\Definitions\FactoryDefinition;
use Nette\DI\Definitions\ServiceDefinition;
use Nextras\Orm\Entity\IEntity;
use Nextras\Orm\Exception\InvalidStateException;
use Nextras\Orm\Exception\InvalidArgumentException;
use Nextras\Orm\Exception\NotSupportedException;
use Nextras\Orm\Repository\IRepository;


class DIRepositoryFinder implements IRepositoryFinder
{
private ContainerBuilder $builder;
private OrmExtension $extension;


// @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/587
#[\Override]
public function __construct(
string $modelClass,
protected readonly array $extensions,
ContainerBuilder $containerBuilder,
OrmExtension $extension,
protected readonly ContainerBuilder $builder,
protected readonly OrmExtension $extension,
protected readonly string $modelClass,
)
{
$this->builder = $containerBuilder;
$this->extension = $extension;
}


public function loadConfiguration(): ?array
#[\Override]
public function registerRepositories(): void
{
return null;
}


public function beforeCompile(): ?array
#[\Override]
public function resolveRepositories(): array
{
$types = $this->findRepositories();
$repositories = [];
$repositoriesMap = [];
foreach ($types as $serviceName => $serviceDefinition) {
$serviceName = (string) $serviceName;
if ($serviceDefinition instanceof FactoryDefinition) {
$serviceDefinition
->getResultDefinition()
->addSetup('setModel', [$this->extension->prefix('@model')]);
$name = $this->getRepositoryName($serviceName, $serviceDefinition);

} elseif ($serviceDefinition instanceof ServiceDefinition || $serviceDefinition instanceof \Nette\DI\ServiceDefinition) { // @phpstan-ignore-line
$serviceDefinition
->addSetup('setModel', [$this->extension->prefix('@model')]);
$name = $this->getRepositoryName($serviceName, $serviceDefinition);

} else {
$type = $serviceDefinition->getType();
throw new InvalidStateException(
"It seems DI defined repository of type '$type' is not defined as one of supported DI services.
Orm can only work with ServiceDefinition or FactoryDefinition services.",
$serviceDefinitions = $this->findRepositories();
foreach ($serviceDefinitions as $serviceName => $serviceDefinition) {
$className = $serviceDefinition->getType();
if ($className === null) {
throw new NotSupportedException(
"Service definition $serviceName does have resolvable class type. " .
"Such service definitions are not support by Nextras Orm."
);
} else if (!is_subclass_of($className, IRepository::class)) {
throw new InvalidArgumentException(
"Found '$className' repository is not an implementation of IRepository."
);
}

/** @var class-string<IRepository<IEntity>> $class */
$class = $serviceDefinition->getType();
$repositories[$name] = $class;
$repositoriesMap[$class] = $serviceName;
$repositories[] = new DiRepositoryEntry(
className: $className,
name: $this->getRepositoryName($serviceName, $serviceDefinition),
service: $serviceDefinition,
);
}

$this->setupRepositoryLoader($repositoriesMap);
return $repositories;
}


/**
* @param FactoryDefinition|ServiceDefinition|ServiceDefinition $serviceDefinition
*/
protected function getRepositoryName(string $serviceName, $serviceDefinition): string
protected function getRepositoryName(string $serviceName, ServiceDefinition $serviceDefinition): string
{
return $serviceName;
}


/**
* @param array<class-string<IRepository<IEntity>>, string> $repositoriesMap
*/
protected function setupRepositoryLoader(array $repositoriesMap): void
{
$this->builder->addDefinition($this->extension->prefix('repositoryLoader'))
->setType(RepositoryLoader::class)
->setArguments([
'repositoryNamesMap' => $repositoriesMap,
'extensions' => $this->extensions,
]);
}


/**
* @return array<string, Definition>
* @return array<string, ServiceDefinition>
*/
protected function findRepositories(): array
{
return $this->builder->findByType(IRepository::class);
$services = [];
foreach ($this->builder->findByType(IRepository::class) as $service) {
$resolvedService = match (true) {
$service instanceof ServiceDefinition => $service,
$service instanceof FactoryDefinition => $service->getResultDefinition(),
default => throw new NotSupportedException("Service " . $service::class . " type is not supported by Nextras Orm.")
};
$services[$resolvedService->getName() ?? $resolvedService->getType() ?? ""] = $resolvedService;
}
return $services;
}
}
22 changes: 22 additions & 0 deletions src/Bridges/NetteDI/DiRepositoryEntry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types = 1);

namespace Nextras\Orm\Bridges\NetteDI;


use Nette\DI\Definitions\ServiceDefinition;
use Nextras\Orm\Repository\IRepository;


class DiRepositoryEntry
{
/**
* @param class-string<IRepository<*>> $className
*/
public function __construct(
public readonly string $className,
public readonly string|null $name,
public readonly ServiceDefinition $service,
)
{
}
}
Loading