feat(form-builder): add BindingTypeRegistry as single source of truth for target shapes (WS-6)

Config-driven mapping from (target_entity, target_attribute) to storage
shape (scalar/collection/relation), PHP type, and identity-key
eligibility. Replaces any name-suffix matching (e.g. _tags, _skills) —
those are convention-not-contract and reject by design.

Used by publish guards now and (in session 2) by FormBindingApplicator.

Refs: RFC-WS-6.md §4 (V1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 22:41:25 +02:00
parent b2e9ef8824
commit 0dd991c688
7 changed files with 356 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use RuntimeException;
/**
* RFC-WS-6 §4 (V1) thrown when a binding configuration is structurally
* invalid for its target shape. Most common case: `merge_strategy=Append`
* on a SCALAR target.
*/
final class InvalidBindingTargetException extends RuntimeException
{
public function __construct(
public readonly string $entity,
public readonly string $attribute,
public readonly string $reason,
) {
parent::__construct(
"Invalid binding target {$entity}.{$attribute}: {$reason}",
);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use RuntimeException;
/**
* RFC-WS-6 §4 (V1) thrown when `BindingTypeRegistry::resolve()` is
* asked about an `(entity, attribute)` pair not in the config.
*/
final class UnknownBindingTargetException extends RuntimeException
{
public function __construct(
public readonly string $entity,
public readonly string $attribute,
) {
parent::__construct(
"Unknown binding target: {$entity}.{$attribute}",
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType;
/**
* RFC-WS-6 §4 (V1) single config row from
* config/form_builder/binding_targets.php, materialised by
* {@see BindingTypeRegistry::resolve()}.
*/
final readonly class BindingTargetMeta
{
public function __construct(
public BindingTargetType $type,
public string $php,
public bool $identityKeyEligible,
) {}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Exceptions\FormBuilder\InvalidBindingTargetException;
use App\Exceptions\FormBuilder\UnknownBindingTargetException;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
/**
* RFC-WS-6 §4 (V1) single source of truth for the storage shape of
* binding-target attributes. Consumed by publish guards
* (AppendStrategyRequiresCollectionTarget) and (in session 2) by
* FormBindingApplicator.
*
* NOT name-suffix matching (e.g. "ends with _tags"); convention-not-
* contract is rejected by design.
*/
final class BindingTypeRegistry
{
/**
* @var array<string, array<string, BindingTargetMeta>>|null
*/
private ?array $cache = null;
public function __construct(private readonly ConfigRepository $config) {}
/**
* @throws UnknownBindingTargetException
*/
public function resolve(string $entity, string $attribute): BindingTargetMeta
{
$entries = $this->resolveTable();
if (! isset($entries[$entity][$attribute])) {
throw new UnknownBindingTargetException($entity, $attribute);
}
return $entries[$entity][$attribute];
}
public function isKnown(string $entity, string $attribute): bool
{
$entries = $this->resolveTable();
return isset($entries[$entity][$attribute]);
}
public function isIdentityKeyEligible(string $entity, string $attribute): bool
{
return $this->isKnown($entity, $attribute)
&& $this->resolve($entity, $attribute)->identityKeyEligible;
}
/**
* @return list<string>
*/
public function entities(): array
{
return array_keys($this->resolveTable());
}
/**
* @return list<string>
*/
public function attributesFor(string $entity): array
{
$entries = $this->resolveTable();
if (! isset($entries[$entity])) {
return [];
}
return array_keys($entries[$entity]);
}
/**
* @throws UnknownBindingTargetException when (entity, attribute) is unknown
* @throws InvalidBindingTargetException when Append is paired with a non-COLLECTION target
*/
public function validateAppendStrategy(
string $entity,
string $attribute,
FormFieldBindingMergeStrategy $strategy,
): void {
if ($strategy !== FormFieldBindingMergeStrategy::Append) {
return;
}
$meta = $this->resolve($entity, $attribute);
if ($meta->type !== BindingTargetType::COLLECTION) {
throw new InvalidBindingTargetException(
entity: $entity,
attribute: $attribute,
reason: "merge_strategy=append requires a COLLECTION target; got {$meta->type->value}",
);
}
}
/**
* @return array<string, array<string, BindingTargetMeta>>
*/
private function resolveTable(): array
{
if ($this->cache !== null) {
return $this->cache;
}
/** @var array<string, array<string, array{type: string, php: string, identity_key_eligible: bool}>> $raw */
$raw = $this->config->get('form_builder.binding_targets', []);
$built = [];
foreach ($raw as $entity => $attributes) {
foreach ($attributes as $attribute => $row) {
$built[$entity][$attribute] = new BindingTargetMeta(
type: BindingTargetType::from($row['type']),
php: $row['php'],
identityKeyEligible: $row['identity_key_eligible'],
);
}
}
$this->cache = $built;
return $built;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Providers;
use App\FormBuilder\Bindings\BindingTypeRegistry;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Models\Company;
use App\Models\CrowdList;
@@ -82,8 +83,13 @@ class AppServiceProvider extends ServiceProvider
base_path('config/form_builder/purposes.php'),
'form_builder.purposes',
);
$this->mergeConfigFrom(
base_path('config/form_builder/binding_targets.php'),
'form_builder.binding_targets',
);
$this->app->singleton(PurposeRegistry::class);
$this->app->singleton(BindingTypeRegistry::class);
// Telescope is a dev-only debugging dashboard. Three-layer
// defense keeps it out of production: composer `dont-discover`

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* RFC-WS-6 §4 (V1) single source of truth for binding-target storage
* shape. Consulted by `BindingTypeRegistry`, `AppendStrategyRequiresCollectionTarget`
* publish guard, and (in session 2) by `FormBindingApplicator`.
*
* `type` values:
* - 'scalar' single column (string/int/datetime/email/...)
* - 'collection' JSON array column or pivot relation with set semantics
* - 'relation' FK column resolving to another entity
*
* `php` values map to native PHP / cast types: 'string', 'int', 'bool',
* 'date', 'datetime', 'array'. Validated by
* BindingTypeRegistryConsistencyTest.
*
* `identity_key_eligible` permits a binding to set `is_identity_key=true`
* on this attribute. PurposeGuardProvider's RequiresIdentityKeyBinding
* may only target attributes that are eligible.
*/
return [
'person' => [
'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true],
'first_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
'last_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
'date_of_birth' => ['type' => 'scalar', 'php' => 'date', 'identity_key_eligible' => false],
'phone_number' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
'dietary_preferences' => ['type' => 'collection', 'php' => 'array', 'identity_key_eligible' => false],
],
'artist' => [
'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true],
'stage_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
'tech_rider' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
'hospitality_rider' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
],
'company' => [
'name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true],
'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true],
'kvk_number' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true],
'phone_number' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
],
'user' => [
'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true],
'first_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
'last_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false],
],
];

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Exceptions\FormBuilder\InvalidBindingTargetException;
use App\Exceptions\FormBuilder\UnknownBindingTargetException;
use App\FormBuilder\Bindings\BindingTypeRegistry;
use Tests\TestCase;
final class BindingTypeRegistryTest extends TestCase
{
public function test_resolve_person_email_returns_scalar_string_identity_eligible(): void
{
$meta = $this->registry()->resolve('person', 'email');
$this->assertSame(BindingTargetType::SCALAR, $meta->type);
$this->assertSame('string', $meta->php);
$this->assertTrue($meta->identityKeyEligible);
}
public function test_resolve_person_dietary_preferences_returns_collection_array(): void
{
$meta = $this->registry()->resolve('person', 'dietary_preferences');
$this->assertSame(BindingTargetType::COLLECTION, $meta->type);
$this->assertSame('array', $meta->php);
$this->assertFalse($meta->identityKeyEligible);
}
public function test_resolve_unknown_attribute_throws(): void
{
$this->expectException(UnknownBindingTargetException::class);
$this->registry()->resolve('person', 'unknown_field');
}
public function test_is_identity_key_eligible_truth_table(): void
{
$registry = $this->registry();
$this->assertTrue($registry->isIdentityKeyEligible('person', 'email'));
$this->assertFalse($registry->isIdentityKeyEligible('person', 'first_name'));
$this->assertFalse($registry->isIdentityKeyEligible('person', 'unknown_field'));
}
public function test_validate_append_strategy_rejects_scalar_target(): void
{
$this->expectException(InvalidBindingTargetException::class);
$this->registry()->validateAppendStrategy(
'person',
'email',
FormFieldBindingMergeStrategy::Append,
);
}
public function test_validate_append_strategy_accepts_collection_target(): void
{
$this->registry()->validateAppendStrategy(
'person',
'dietary_preferences',
FormFieldBindingMergeStrategy::Append,
);
$this->expectNotToPerformAssertions();
}
public function test_validate_append_strategy_skips_other_strategies(): void
{
$this->registry()->validateAppendStrategy(
'person',
'email',
FormFieldBindingMergeStrategy::Overwrite,
);
$this->expectNotToPerformAssertions();
}
public function test_entities_returns_known_entities(): void
{
$entities = $this->registry()->entities();
sort($entities);
$this->assertSame(['artist', 'company', 'person', 'user'], $entities);
}
public function test_attributes_for_person_includes_email_and_dietary_preferences(): void
{
$attributes = $this->registry()->attributesFor('person');
$this->assertContains('email', $attributes);
$this->assertContains('dietary_preferences', $attributes);
}
public function test_attributes_for_unknown_entity_returns_empty_list(): void
{
$this->assertSame([], $this->registry()->attributesFor('unknown'));
}
private function registry(): BindingTypeRegistry
{
return $this->app->make(BindingTypeRegistry::class);
}
}