Per RFC-WS-6 §Q3 v1.3 addition 2 (binding hierarchy) + §Q2 (invariant exception).
- Refactored FormBindingApplicatorException from concrete final to abstract
base. Constructor (submissionId, message, previous?) preserves submissionId
as a public readonly property so D2's outer-transaction handler can write
it structurally to form_submission_action_failures.context JSON without
regex-parsing the message. Replaced public-readonly reasonCode property
with abstract reasonCode(): string method.
- Added 3 reason-coded subclasses:
- FormBindingSchemaConfigException -> 'schema_config_error' (422)
- FormBindingInfraException -> 'temporary_error' (503, NOT final because
Timeout extends it)
- FormBindingDataIntegrityException -> 'data_integrity_error' (422)
- Added FormBindingApplicatorTimeoutException extending FormBindingInfraException
(timeout = temporary infra issue from user perspective; reasonCode inherited).
- Added IdentityMatchInvariantViolation as a sibling DomainException — NOT
in the FormBindingApplicatorException hierarchy because it's thrown
outside the binding-applicator pipeline.
- Migrated 3 existing throw sites in FormBindingApplicator::apply():
- 'no_transaction' -> FormBindingInfraException (developer-error wants
infra-triage workflow: GlitchTip alert + retry-after)
- 'no_schema' -> FormBindingSchemaConfigException
- 'unknown_purpose' -> FormBindingSchemaConfigException
- Updated FormBindingApplicatorIntegrationTest::test_no_transaction_guard_present
to assert against the new throw shape (FormBindingInfraException + new
message string) while preserving the test's intent (guard exists in source).
Wiring (deadline wrapper, classifier integration in listener catch +
retry-service recordFailure) lands in D2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
213 lines
8.2 KiB
PHP
213 lines
8.2 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\Exceptions\FormBuilder\FormBindingInfraException;
|
|
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
|
|
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 FormBindingInfraException(
|
|
submissionId: (string) $submission->id,
|
|
message: 'FormBindingApplicator must be invoked inside DB::transaction',
|
|
);
|
|
}
|
|
|
|
/** @var \App\Models\FormBuilder\FormSchema|null $schema */
|
|
$schema = $submission->schema;
|
|
if ($schema === null) {
|
|
throw new FormBindingSchemaConfigException(
|
|
submissionId: (string) $submission->id,
|
|
message: "schema null for submission {$submission->id}",
|
|
);
|
|
}
|
|
$purposeValue = $schema->purpose->value;
|
|
if (! $this->purposeRegistry->has($purposeValue)) {
|
|
throw new FormBindingSchemaConfigException(
|
|
submissionId: (string) $submission->id,
|
|
message: "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();
|
|
}
|
|
}
|