Files
crewli/api/app/FormBuilder/Bindings/FormBindingApplicator.php
bert.hausmans 6b5111ce43 feat(form-builder): ApplyBindings listener chain with two-transaction pattern (WS-6)
ApplyBindingsOnFormSubmit (sync) wraps the applicator in DB::transaction
and writes apply_status post-commit. On exception: outer catch records
FormSubmissionActionFailure in a separate transaction (survives inner
rollback), marks apply_status=failed, swallows so siblings keep running
(RFC Q3, Q4). When ApplyBindings provisions a Person on a previously
no-subject submission, the listener also writes subject_type/subject_id
back so TriggerPersonIdentityMatchOnFormSubmit (next sync listener) can
find the freshly-provisioned subject.

ApplyBindingsOnFormSectionSubmitted (queued, feature-flagged) ready
for ARTIST_ADVANCE activation per RFC Q10.

Listener chain on FormSubmissionSubmitted explicitly registered in
AppServiceProvider::boot for deterministic ordering (RFC Q1):
ApplyBindings → IdentityMatch → queued siblings.

FormBindingApplicator dropped 'final readonly' to 'class' so listener
tests can subclass it for throw-path coverage; constructor properties
remain readonly individually.

Refs: RFC-WS-6.md §3 (Q1, Q3, Q4, Q10)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:18:30 +02:00

212 lines
8.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* RFC-WS-6 §3 — orchestrator for the binding pipeline. Calls the
* subject resolver, the conflict resolver, and writes target attributes.
*
* - Q4: caller MUST be inside a DB::transaction; this method does
* not open its own. Per-binding write failures are captured in the
* result, not thrown. Catastrophic failures (no transaction,
* unknown purpose, missing schema) bubble.
* - Q7: per-strategy null-winner matrix via
* FormFieldBindingMergeStrategy::nullWinnerBehaviour().
* - Q9: subject resolution via per-purpose PurposeSubjectResolver.
* - Q10: optional sectionId for future section-level apply.
* - Q12: hierarchical activity log via BindingActivityLogger.
*/
// Not final + not readonly: listener tests need to override `apply()` for
// throw-path coverage (Mockery can't mock final classes; PHP doesn't allow
// extending readonly with non-readonly child). Properties stay readonly
// individually to preserve immutability.
class FormBindingApplicator
{
public function __construct(
private readonly PurposeRegistry $purposeRegistry,
private readonly BindingConflictResolver $conflictResolver,
private readonly BindingTypeRegistry $typeRegistry,
private readonly BindingActivityLogger $activityLogger,
) {}
/**
* @throws FormBindingApplicatorException
*/
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{
if (DB::transactionLevel() < 1) {
throw new FormBindingApplicatorException(
'no_transaction',
(string) $submission->id,
'FormBindingApplicator must be invoked inside DB::transaction',
);
}
/** @var \App\Models\FormBuilder\FormSchema|null $schema */
$schema = $submission->schema;
if ($schema === null) {
throw new FormBindingApplicatorException(
'no_schema',
(string) $submission->id,
);
}
$purposeValue = $schema->purpose->value;
if (! $this->purposeRegistry->has($purposeValue)) {
throw new FormBindingApplicatorException(
'unknown_purpose',
(string) $submission->id,
"purpose '{$purposeValue}' not registered",
);
}
$resolver = $this->purposeRegistry->subjectResolverFor($purposeValue);
$subject = $resolver->resolveOrProvision($submission);
if (! $subject instanceof Model) {
// Anonymous-allowed (incident_report). No bindings to apply.
$result = new BindingPassResult(
formSubmissionId: (string) $submission->id,
provisionedSubjectType: null,
provisionedSubjectId: null,
applications: [],
);
$this->activityLogger->logPass($submission, $result);
return $result;
}
$resolved = $this->conflictResolver->resolve($submission, $sectionId);
// Persist subject identity for the result + apply each binding.
$applications = [];
foreach ($resolved as $binding) {
// Skip identity-key bindings — the resolver already used them
// for subject lookup in EventRegistration's PersonProvisioner
// path. Writing them again is a no-op at best, a clobber at
// worst.
if ($binding->isIdentityKey) {
continue;
}
$applications[] = $this->applyOne($subject, $binding);
}
$result = new BindingPassResult(
formSubmissionId: (string) $submission->id,
provisionedSubjectType: $this->morphAlias($subject),
provisionedSubjectId: (string) $subject->getKey(),
applications: $applications,
);
$this->activityLogger->logPass($submission, $result);
return $result;
}
private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult
{
try {
// Defensive: BindingTypeRegistry validates Append-against-scalar
// at publish time; runtime check is a failsafe for live-table
// edits between publish and apply.
$this->typeRegistry->validateAppendStrategy(
$binding->targetEntity,
$binding->targetAttribute,
$binding->mergeStrategy,
);
$oldValue = $subject->getAttribute($binding->targetAttribute);
$newValue = $this->computeNewValue($oldValue, $binding);
if ($newValue === self::NO_OP) {
return BindingApplicationResult::succeeded(
bindingId: $binding->bindingId,
targetEntity: $binding->targetEntity,
targetAttribute: $binding->targetAttribute,
oldValue: $oldValue,
newValue: $oldValue,
);
}
$subject->setAttribute($binding->targetAttribute, $newValue);
$subject->save();
return BindingApplicationResult::succeeded(
bindingId: $binding->bindingId,
targetEntity: $binding->targetEntity,
targetAttribute: $binding->targetAttribute,
oldValue: $oldValue,
newValue: $newValue,
);
} catch (Throwable $e) {
return BindingApplicationResult::failed(
bindingId: $binding->bindingId,
targetEntity: $binding->targetEntity,
targetAttribute: $binding->targetAttribute,
e: $e,
);
}
}
private const NO_OP = '__binding_noop_sentinel__';
private function computeNewValue(mixed $oldValue, ResolvedBinding $binding): mixed
{
$newValue = $binding->value;
$strategy = $binding->mergeStrategy;
// Per-strategy matrix. RFC §3 Q7.
if ($newValue === null) {
$behaviour = $strategy->nullWinnerBehaviour();
return match ($behaviour) {
'write' => null,
'noop' => self::NO_OP,
'conditional' => $oldValue === null ? null : self::NO_OP,
default => self::NO_OP,
};
}
return match ($strategy) {
FormFieldBindingMergeStrategy::Overwrite => $newValue,
FormFieldBindingMergeStrategy::Append => $this->appendCollection($oldValue, $newValue, $binding),
FormFieldBindingMergeStrategy::Replace => $oldValue === null ? $newValue : self::NO_OP,
FormFieldBindingMergeStrategy::FirstWriteWins => $oldValue === null ? $newValue : self::NO_OP,
};
}
private function appendCollection(mixed $oldValue, mixed $newValue, ResolvedBinding $binding): mixed
{
if ($binding->targetType !== BindingTargetType::COLLECTION) {
// Defensive — publish guard should prevent this. Throwing
// gets the failure into BindingApplicationResult::failed.
throw new \InvalidArgumentException(
"merge_strategy=append requires COLLECTION target; got {$binding->targetType->value}",
);
}
$current = is_array($oldValue) ? $oldValue : [];
$incoming = is_array($newValue) ? $newValue : [$newValue];
// Set semantics: dedupe via array_unique. Preserves insertion order
// for stable activity log output.
$merged = array_values(array_unique(array_merge($current, $incoming), SORT_REGULAR));
return $merged;
}
private function morphAlias(Model $subject): string
{
return $subject->getMorphClass();
}
}