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
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
<!-- file generated with AI assistance: Claude Code - 2026-07-22 -->
<!-- file generated with AI assistance: Claude Code - 2026-07-22, revised 2026-10-08 13:37:13 UTC -->

# Changelog

## 0.5.0 (2026-10-09)

### Added

- `auto_order` (opt-in, default off): generated `order[<property>]` query parameters (API Platform `SortFilter`) for the `GetCollection` operations of Doctrine ORM resources — sortable scalar fields (type whitelist, identifiers excluded) and to-one relations as `order[<relation>.<label>]`, label from `relation_field_decorator.label_property_candidates` only. Only API-readable properties (name collection, `readable`, normalization groups) are offered; explicit `#[ApiFilter(OrderFilter::class)]` declarations and parameters the operation declares itself win per property; operations with their own `provider` are skipped; DTO resources are mapped via `stateOptions` `entityClass`. Requires API Platform >= 4.3.
- `auto_order.default_order` (default `{name: ASC, createdAt: DESC}`): default sort for `GetCollection` operations without an own `order`; the first key that is a sortable property of the resource wins.
- `#[AutoOrder(enabled: false)]` / `#[AutoOrder(exclude: [...])]` attribute to switch auto-ordering off or restrict it per class.
- `stable_order` (opt-in, default off): `StableOrderExtension` appends `ORDER BY <identifier> ASC` to Doctrine ORM collection queries that do not sort by the identifier yet (priority -40, between `OrderExtension` and pagination), for stable paging over non-unique sort columns. Skipped for composite/foreign identifiers and `GROUP BY` queries.

### Changed

- `email` added to the default `relation_field_decorator.label_property_candidates` (`name`, `title`, `label`, `displayName`, `email`). Projects that set the list explicitly are not affected.
- Label property candidates from several config files are de-duplicated (list nodes are appended to each other when merged).
- `symfony/yaml` moved from `require-dev` to `require`: the bundle extension always loads `config/services.yaml` via `YamlFileLoader`, so it is a runtime dependency.

### Fixed

- Relation extensions are also added to properties inside `allOf` (JSON-LD output definitions wrap their properties next to the Hydra base schema), so JSON-LD read schemas get the hints introduced in 0.4.1.

## 0.4.1 (2026-10-09)

### Fixed
Expand Down
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- file generated with AI assistance: Claude Code - 2026-06-09 19:27:00 UTC -->
<!-- file generated with AI assistance: Claude Code - 2026-06-09 19:27:00 UTC, revised 2026-10-08 13:35:00 UTC -->

# dmstr/api-platform-utils-bundle

Expand All @@ -13,6 +13,8 @@ Generic helpers for API Platform projects.
OpenAPI schema factory for autocomplete-rendered IRI collections
- DB-JSON CLI tools (`db:export-json`, `db:import-json`)
- `AbstractJsonSchemaInputCommand` base class for JSON-Schema-validated CLI
- Generated sort parameters, default sort and a stable-pagination tie-breaker (`auto_order`, `#[AutoOrder]`, `stable_order`)
- Relation label (`x-label-property`) in input and output schemas

## Hydra operations (`hydra:operation`)

Expand Down Expand Up @@ -49,6 +51,67 @@ dmstr_api_platform_utils:

With filtering active, item and collection responses become **user-dependent**: the subscriber emits `Vary: Authorization` so shared HTTP caches never serve one user's operation set to another. The static `/api/docs.jsonld` stays role-independent and cacheable. If your stack caches API responses in a reverse proxy, verify it honors `Vary` — otherwise exclude these responses from caching.

## Sorting

### Generated sort parameters (`auto_order`)

With `auto_order.enabled`, every `GetCollection` operation of a Doctrine ORM resource gets one `order[<property>]` query parameter per sortable property, backed by API Platform's `SortFilter`. They appear in the OpenAPI document and in `hydra:search`, so a client can derive its sortable columns from the API instead of guessing. Requires API Platform >= 4.3 (nested `SortFilter` support).

Which properties get a parameter:

- **Scalar fields** whose Doctrine type is sortable: `string`, `ascii_string`, `integer`, `smallint`, `bigint`, `float`, `smallfloat`, `decimal`, `boolean`, `date`, `datetime`, `datetimetz`, `time` (and their `_immutable` variants). Not offered: identifiers of any type, `text`, `json`, `array`/`simple_array`, `blob`/`binary`, `guid`/`uuid`, custom types such as `vector`, and embedded fields.
- **To-one relations** (`ManyToOne`, owning `OneToOne`) as `order[<relation>.<label>]`, sorted by the related entity's label via a `LEFT JOIN` (rows without a relation stay in the list). The label is the first entry of `relation_field_decorator.label_property_candidates` that is a sortable, readable field of the target entity. There is no fallback: a relation without a candidate gets no parameter.
- Only properties the API exposes: the property must be in API Platform's property name collection and readable with the operation's normalization groups. `#[ApiProperty(readable: false)]` and properties outside the groups get no parameter.

Precedence and exclusions:

- A parameter the operation declares itself under the same key wins.
- An explicit `#[ApiFilter(OrderFilter::class, properties: [...])]` (class or property level) wins per property; a class-level `OrderFilter` without `properties` covers all properties, so no parameter is generated then. Order filters registered by service id (`ApiResource(filters: [...])`) are not recognised.
- Operations with their own `provider` are skipped. DTO resources are mapped to their entity via `stateOptions: new Options(entityClass: ...)`; the DTO's properties decide what is exposed.

```yaml
dmstr_api_platform_utils:
relation_field_decorator:
label_property_candidates: [name, title, label, displayName, email]
auto_order:
enabled: true
# default sort for GetCollection operations without an own `order`
default_order:
name: ASC
createdAt: DESC
```

### Default sort (`auto_order.default_order`)

A `GetCollection` operation without an own `order` gets the first entry of `default_order` that is a sortable property of the resource as its `order` (default: `name ASC`, else `createdAt DESC`). API Platform's `OrderExtension` applies it only when the client sends no sort parameter, so a client sort always replaces it. Without a match the operation keeps API Platform's own default (identifier). An empty map (`default_order: {}`) switches the default sort off. The map is replaced, not merged, when several config files set it.

### Per class: `#[AutoOrder]`

```php
use Dmstr\ApiPlatformUtils\Attribute\AutoOrder;

#[AutoOrder(enabled: false)] // no parameters, no default sort
#[AutoOrder(exclude: ['hash', 'collection'])] // no order[hash], no order[collection.<label>]
```

The attribute is read from the resource class and, for DTO resources, from the entity class too. An `exclude` entry matches the path and everything below it; excluded properties are no default sort candidates either.

### Stable pagination (`stable_order`)

Sorting by a non-unique column leaves the order of equal rows to the database, which may differ between two page requests: rows show up twice or never while paging. With `stable_order.enabled`, `StableOrderExtension` appends `ORDER BY <root>.<identifier> ASC` to every Doctrine ORM collection query whose ORDER BY does not contain the identifier yet. It is tagged with priority -40, after the sort (`OrderExtension`, -32) and before pagination (-64). Entities with a composite or foreign identifier and queries with `GROUP BY` are left alone.

```yaml
dmstr_api_platform_utils:
stable_order:
enabled: true
```

## Relation labels in schemas (`x-label-property`)

`RelationFieldSchemaDecorator` adds `x-*` extensions to relation properties (`format: iri-reference`) of Doctrine resources:

Input and output schemas get the same five extensions: `x-collection`, `x-label-property`, `x-value-property`, `x-search-property` and `x-resource-class`. Forms built from the read schema (resources with serialization groups have no plain schema) get the autocomplete picker, and lists can show and sort a relation by its label (`order[<relation>.<label>]`, offered by `auto_order` only when the label is one of the candidates). The label is the first candidate the target class declares, else its first string property. Properties inside `allOf` are decorated too, which is where JSON-LD output definitions keep them.

## License

MIT © diemeisterei GmbH
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"symfony/uid": "^7.0",
"api-platform/core": "^3.0|^4.0",
"doctrine/orm": "^2.0|^3.0",
"psr/log": "^3.0"
"psr/log": "^3.0",
"symfony/yaml": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
Expand Down
24 changes: 24 additions & 0 deletions config/services_auto_order.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# file generated with AI assistance: Claude Code - 2026-10-08 12:20:00 UTC

services:
_defaults:
autowire: false
autoconfigure: false
public: false

# Auto-generated order[<property>] query parameters (SortFilter) and the
# default sort for GetCollection operations of Doctrine ORM resources.
# Priority 1100 runs before API Platform's parameter factory (1000),
# which completes schema, OpenAPI and nested_properties_info of the
# generated parameters.
Dmstr\ApiPlatformUtils\Metadata\AutoOrderResourceMetadataCollectionFactory:
decorates: 'api_platform.metadata.resource.metadata_collection_factory'
decoration_priority: 1100
arguments:
$decorated: '@.inner'
$managerRegistry: '@doctrine'
$propertyNameCollectionFactory: '@api_platform.metadata.property.name_collection_factory'
$propertyMetadataFactory: '@api_platform.metadata.property.metadata_factory'
$labelPropertyCandidates: '%dmstr_api_platform_utils.relation_field_decorator.label_property_candidates%'
$defaultOrder: '%dmstr_api_platform_utils.auto_order.default_order%'
$orderParameterName: '%api_platform.collection.order_parameter_name%'
14 changes: 14 additions & 0 deletions config/services_stable_order.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# file generated with AI assistance: Claude Code - 2026-10-08 12:20:00 UTC

services:
_defaults:
autowire: false
autoconfigure: false
public: false

# Tie-breaker for stable pagination: appends ORDER BY <identifier> ASC
# to collection queries. Priority -40 runs after OrderExtension (-32)
# and before pagination (-64).
Dmstr\ApiPlatformUtils\Doctrine\Orm\Extension\StableOrderExtension:
tags:
- { name: 'api_platform.doctrine.orm.query_extension.collection', priority: -40 }
52 changes: 52 additions & 0 deletions src/Attribute/AutoOrder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-08 12:05:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiPlatformUtils\Attribute;

use Attribute;

/**
* Per-class switch for the auto-generated `order[<property>]` parameters
* and the default sort (config `auto_order`, see
* {@see \Dmstr\ApiPlatformUtils\Metadata\AutoOrderResourceMetadataCollectionFactory}).
*
* Put it on the API resource class or on its Doctrine entity class (when
* they differ, e.g. a DTO resource with `stateOptions`); both are read.
*
* Examples:
*
* // no generated sort parameters and no default sort for this resource
* #[AutoOrder(enabled: false)]
*
* // no order[hash] and no order[collection.<label>]
* #[AutoOrder(exclude: ['hash', 'collection'])]
*
* An `exclude` entry matches the property path itself and everything below
* it: `collection` excludes `collection.name`. Excluded properties are not
* used for the default sort either.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class AutoOrder
{
/**
* @param list<string> $exclude property paths that get no generated parameter
*/
public function __construct(
public readonly bool $enabled = true,
public readonly array $exclude = [],
) {
}

public function excludes(string $path): bool
{
foreach ($this->exclude as $excluded) {
if ($path === $excluded || str_starts_with($path, $excluded.'.')) {
return true;
}
}

return false;
}
}
27 changes: 25 additions & 2 deletions src/DependencyInjection/ApiPlatformUtilsExtension.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<?php
// file generated with AI assistance: Claude Code - 2025-11-22
// file generated with AI assistance: Claude Code - 2025-11-22, revised 2026-10-08 12:25:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiPlatformUtils\DependencyInjection;

use ApiPlatform\Doctrine\Orm\Filter\SortFilter;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
Expand All @@ -27,7 +29,8 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('dmstr_api_platform_utils.relation_field_decorator.enabled', $config['relation_field_decorator']['enabled']);
$container->setParameter('dmstr_api_platform_utils.relation_field_decorator.api_prefix', $config['relation_field_decorator']['api_prefix']);
$container->setParameter('dmstr_api_platform_utils.relation_field_decorator.decoration_priority', $config['relation_field_decorator']['decoration_priority']);
$container->setParameter('dmstr_api_platform_utils.relation_field_decorator.label_property_candidates', $config['relation_field_decorator']['label_property_candidates']);
// de-duplicated: list nodes of several config files are appended to each other
$container->setParameter('dmstr_api_platform_utils.relation_field_decorator.label_property_candidates', array_values(array_unique($config['relation_field_decorator']['label_property_candidates'])));

$container->setParameter('dmstr_api_platform_utils.hydra_operations.enabled', $config['hydra_operations']['enabled']);
$container->setParameter('dmstr_api_platform_utils.hydra_operations.api_prefix', $config['hydra_operations']['api_prefix']);
Expand All @@ -42,6 +45,11 @@ public function load(array $configs, ContainerBuilder $container): void

$container->setParameter('dmstr_api_platform_utils.partial_uuid_item_provider.enabled', $config['partial_uuid_item_provider']['enabled']);

$container->setParameter('dmstr_api_platform_utils.auto_order.enabled', $config['auto_order']['enabled']);
$container->setParameter('dmstr_api_platform_utils.auto_order.default_order', $config['auto_order']['default_order']);

$container->setParameter('dmstr_api_platform_utils.stable_order.enabled', $config['stable_order']['enabled']);

// Load service definitions
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
$loader->load('services.yaml');
Expand All @@ -52,6 +60,21 @@ public function load(array $configs, ContainerBuilder $container): void
if ($config['partial_uuid_item_provider']['enabled']) {
$loader->load('services_partial_uuid.yaml');
}

// Conditionally load the auto-order metadata factory. Off by default:
// it adds query parameters to every Doctrine GetCollection operation.
if ($config['auto_order']['enabled']) {
if (!class_exists(SortFilter::class)) {
throw new InvalidConfigurationException('dmstr_api_platform_utils.auto_order requires API Platform >= 4.3 (ApiPlatform\\Doctrine\\Orm\\Filter\\SortFilter with nested property support).');
}
$loader->load('services_auto_order.yaml');
}

// Conditionally load the tie-breaker query extension. Off by default:
// it changes the ORDER BY of every Doctrine collection query.
if ($config['stable_order']['enabled']) {
$loader->load('services_stable_order.yaml');
}
}

public function getAlias(): string
Expand Down
45 changes: 42 additions & 3 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?php
// file generated with AI assistance: Claude Code - 2025-11-22
// file generated with AI assistance: Claude Code - 2025-11-22, revised 2026-10-08 12:25:00 UTC

declare(strict_types=1);

Expand Down Expand Up @@ -54,8 +54,8 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->arrayNode('label_property_candidates')
->scalarPrototype()->end()
->defaultValue(['name', 'title', 'label', 'displayName'])
->info('Property names to check for entity labels (in order of preference)')
->defaultValue(['name', 'title', 'label', 'displayName', 'email'])
->info('Property names to check for entity labels (in order of preference); also used by auto_order for the label of to-one relations')
->end()
->end()
->end()
Expand Down Expand Up @@ -113,6 +113,45 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()

// Auto-generated order[<property>] parameters and default sort
->arrayNode('auto_order')
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')
->defaultFalse()
->info('Generate order[<property>] query parameters (SortFilter) for the GetCollection operations of Doctrine ORM resources: sortable scalar fields and to-one relations via their label property (relation_field_decorator.label_property_candidates). Properties with an explicit #[ApiFilter(OrderFilter::class)] are left alone; #[AutoOrder] restricts or disables it per class. Requires API Platform >= 4.3.')
->end()
->arrayNode('default_order')
->info('Default sort for GetCollection operations without an own `order`: the first property that is sortable on the resource wins, e.g. {name: ASC, createdAt: DESC}. Empty map = no default sort (API Platform orders by identifier).')
->useAttributeAsKey('property')
->normalizeKeys(false)
->performNoDeepMerging()
->scalarPrototype()
->beforeNormalization()
->ifString()
->then(static fn (string $v): string => strtoupper($v))
->end()
->validate()
->ifNotInArray(['ASC', 'DESC'])
->thenInvalid('Invalid sort direction %s, expected ASC or DESC.')
->end()
->end()
->defaultValue(['name' => 'ASC', 'createdAt' => 'DESC'])
->end()
->end()
->end()

// Tie-breaker for stable pagination
->arrayNode('stable_order')
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')
->defaultFalse()
->info('Append ORDER BY <identifier> ASC to Doctrine ORM collection queries whose ORDER BY does not contain the identifier, so that paging over a non-unique sort column is stable. Skipped for composite/foreign identifiers and GROUP BY queries.')
->end()
->end()
->end()

// Partial UUID Item Provider
->arrayNode('partial_uuid_item_provider')
->addDefaultsIfNotSet()
Expand Down
Loading
Loading