*/ public function rulesFor(FormField|FormFieldLibrary $owner): Collection { $type = $this->ownerTypeFor($owner); return FormFieldValidationRule::query() ->where('owner_type', $type) ->where('owner_id', $owner->getKey()) ->get(); } /** * Replace the full rule set on an owner transactionally. Validates every * spec's rule_type + parameter shape before any write lands. * * @param list,error_message_key?:?string}> $specs */ public function replaceRules(FormField|FormFieldLibrary $owner, array $specs): void { $this->assertSpecsValid($specs); $ownerType = $this->ownerTypeFor($owner); DB::transaction(function () use ($owner, $ownerType, $specs): void { FormFieldValidationRule::query() ->withoutGlobalScopes() ->where('owner_type', $ownerType) ->where('owner_id', $owner->getKey()) ->delete(); foreach ($specs as $spec) { FormFieldValidationRule::query()->withoutGlobalScopes()->create([ 'owner_type' => $ownerType, 'owner_id' => $owner->getKey(), 'rule_type' => $spec['rule_type'], 'parameters' => $spec['parameters'] ?? [], 'error_message_key' => $spec['error_message_key'] ?? null, ]); } if ($owner instanceof FormField) { $owner->logFieldChange('field.validation_rules_replaced', [ 'count' => count($specs), ]); } }); } /** * Row-copy from a library entry to a freshly-inserted field (addendum * Q3 row-copy mandate). Every column is preserved; only `owner_type` / * `owner_id` change. */ public function copyRules(FormFieldLibrary $from, FormField $to): void { $rules = $this->rulesFor($from); if ($rules->isEmpty()) { return; } DB::transaction(function () use ($rules, $to): void { foreach ($rules as $rule) { FormFieldValidationRule::query()->withoutGlobalScopes()->create([ 'owner_type' => 'form_field', 'owner_id' => $to->id, 'rule_type' => $rule->rule_type instanceof FormFieldValidationRuleType ? $rule->rule_type->value : (string) $rule->rule_type, 'parameters' => (array) $rule->parameters, 'error_message_key' => $rule->error_message_key, ]); } }); } /** * Serialise a rule collection to the flat JSON bag shape consumed by * snapshot writer and API resources. Returns null on empty — matches * pre-WS-5b `validation_rules: null`. * * Shape uses the canonical rule_type keys (new post-WS-5b names — the * legacy ambiguous `min` / `max` were renamed at backfill to * `min_length`/`min_value` etc., and `max_priorities` canonicalised * to `max_selected`). * * @param Collection $rules * @return array|null */ public function toJsonShape(Collection $rules): ?array { if ($rules->isEmpty()) { return null; } // ULID id sort = insertion-order semantics, deterministic across DB // engines. Without it, MySQL returns rows in unspecified order and // schema_snapshot bytes drift across re-emits — breaks audit replay. $out = []; foreach ($rules->sortBy('id') as $rule) { $type = $rule->rule_type instanceof FormFieldValidationRuleType ? $rule->rule_type->value : (string) $rule->rule_type; $params = (array) $rule->parameters; $out[$type] = $this->flattenParameters($type, $params); } return $out; } /** * Canonical flat rendering per rule_type — mirrors the pre-WS-5b bag * shape convention (scalar value for single-value rules, array for * collections, boolean `true` for no-param markers). * * @param array $params */ private function flattenParameters(string $ruleType, array $params): mixed { return match ($ruleType) { FormFieldValidationRuleType::MinLength->value, FormFieldValidationRuleType::MaxLength->value, FormFieldValidationRuleType::MinValue->value, FormFieldValidationRuleType::MaxValue->value, FormFieldValidationRuleType::MinSelected->value, FormFieldValidationRuleType::MaxSelected->value => $params['value'] ?? null, FormFieldValidationRuleType::MaxFileSize->value => $params['bytes'] ?? null, FormFieldValidationRuleType::Regex->value => isset($params['flags']) && $params['flags'] !== '' ? ['pattern' => $params['pattern'] ?? '', 'flags' => $params['flags']] : ($params['pattern'] ?? ''), FormFieldValidationRuleType::AllowedMimeTypes->value => $params['mime_types'] ?? [], FormFieldValidationRuleType::DateMin->value, FormFieldValidationRuleType::DateMax->value => $params['date'] ?? null, FormFieldValidationRuleType::Callback->value => $params['key'] ?? '', FormFieldValidationRuleType::EmailFormat->value, FormFieldValidationRuleType::UrlFormat->value, FormFieldValidationRuleType::PhoneE164->value => true, default => $params, }; } /** * Public wrapper around `assertSpecValid` — iterates a caller-supplied * list and throws the first `UnknownValidationRuleTypeException`. * Used by FormRequests (WS-5b commit 3 strict validator on save) to * reject bad specs at the HTTP boundary before any write lands. * * @param list> $specs */ public function assertSpecsValid(array $specs): void { foreach ($specs as $spec) { $this->assertSpecValid($spec); } } private function ownerTypeFor(FormField|FormFieldLibrary $owner): string { return $owner instanceof FormField ? 'form_field' : 'form_field_library'; } /** @param array $spec */ private function assertSpecValid(array $spec): void { $ruleTypeRaw = (string) ($spec['rule_type'] ?? ''); $enum = FormFieldValidationRuleType::tryFrom($ruleTypeRaw); if ($enum === null) { throw new UnknownValidationRuleTypeException( "Validation rule_type '{$ruleTypeRaw}' is not a registered " .'FormFieldValidationRuleType case.', ); } $params = (array) ($spec['parameters'] ?? []); $this->assertParametersMatchShape($enum, $params); } /** @param array $params */ private function assertParametersMatchShape(FormFieldValidationRuleType $type, array $params): void { switch ($type) { case FormFieldValidationRuleType::MinLength: case FormFieldValidationRuleType::MaxLength: case FormFieldValidationRuleType::MinSelected: case FormFieldValidationRuleType::MaxSelected: $this->requireInt($type, $params, 'value'); return; case FormFieldValidationRuleType::MinValue: case FormFieldValidationRuleType::MaxValue: $this->requireNumeric($type, $params, 'value'); return; case FormFieldValidationRuleType::MaxFileSize: $this->requireInt($type, $params, 'bytes'); return; case FormFieldValidationRuleType::Regex: $this->requireString($type, $params, 'pattern'); return; case FormFieldValidationRuleType::AllowedMimeTypes: if (! isset($params['mime_types']) || ! is_array($params['mime_types'])) { throw new UnknownValidationRuleTypeException( "Validation rule '{$type->value}' requires parameters.mime_types (array of strings).", ); } foreach ($params['mime_types'] as $mime) { if (! is_string($mime) || $mime === '') { throw new UnknownValidationRuleTypeException( "Validation rule '{$type->value}' parameters.mime_types must be non-empty strings.", ); } } return; case FormFieldValidationRuleType::DateMin: case FormFieldValidationRuleType::DateMax: $this->requireString($type, $params, 'date'); return; case FormFieldValidationRuleType::Callback: $this->requireString($type, $params, 'key'); $registered = (array) config('form_builder.validation_callbacks', []); if (! array_key_exists($params['key'], $registered)) { throw new UnknownValidationRuleTypeException( "Validation callback '{$params['key']}' is not registered in " .'config/form_builder.php under `validation_callbacks`.', ); } return; case FormFieldValidationRuleType::EmailFormat: case FormFieldValidationRuleType::UrlFormat: case FormFieldValidationRuleType::PhoneE164: // Boolean markers — parameters must be empty or absent. Any // unexpected keys signal a caller bug; reject loudly. if ($params !== []) { throw new UnknownValidationRuleTypeException( "Validation rule '{$type->value}' takes no parameters; got ".count($params).'.', ); } return; } } /** @param array $params */ private function requireInt(FormFieldValidationRuleType $type, array $params, string $key): void { if (! isset($params[$key]) || ! is_int($params[$key])) { throw new UnknownValidationRuleTypeException( "Validation rule '{$type->value}' requires integer parameters.{$key}.", ); } } /** @param array $params */ private function requireNumeric(FormFieldValidationRuleType $type, array $params, string $key): void { if (! isset($params[$key]) || ! is_numeric($params[$key])) { throw new UnknownValidationRuleTypeException( "Validation rule '{$type->value}' requires numeric parameters.{$key}.", ); } } /** @param array $params */ private function requireString(FormFieldValidationRuleType $type, array $params, string $key): void { if (! isset($params[$key]) || ! is_string($params[$key]) || $params[$key] === '') { throw new UnknownValidationRuleTypeException( "Validation rule '{$type->value}' requires non-empty string parameters.{$key}.", ); } } }