deadlineSeconds = $seconds; return $clone; } /** * @throws FormBindingApplicatorException */ public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult { $start = microtime(true); 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: [], ); } else { $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); $this->checkDeadline((string) $submission->id, $start); return $result; } /** * Throws FormBindingApplicatorTimeoutException if a deadline is * configured and the elapsed wall-clock time exceeds it. */ private function checkDeadline(string $submissionId, float $startMicrotime): void { if ($this->deadlineSeconds === null) { return; } $elapsed = microtime(true) - $startMicrotime; if ($elapsed > $this->deadlineSeconds) { throw new FormBindingApplicatorTimeoutException( submissionId: $submissionId, message: sprintf( 'FormBindingApplicator exceeded deadline of %ds (elapsed: %.2fs) for submission %s', $this->deadlineSeconds, $elapsed, $submissionId, ), ); } } 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(); } }