Files
crewli/api/app/Services/FormBuilder/FormFieldValidationRuleService.php
bert.hausmans 3d323bf55f chore(test): switch test database from SQLite to MySQL (WS-6)
Test infrastructure now uses the same MySQL 8.0 engine as local dev
and production. SQLite is no longer used anywhere in the project.

Eliminates the SQLite "rebuild on FK add" quirk that forced session 2.5
to omit a foreign key on form_schemas.default_crowd_type_id (Task 2 of
this session restores it).

Configuration:
- phpunit.xml: DB_CONNECTION=sqlite (:memory:) replaced with mysql
  pointing at crewli_test database (127.0.0.1:3306, crewli/secret)
- Makefile: new test-db-create target creates crewli_test in the
  bm_mysql Docker container; make test ensures it exists before
  running suite

Latent-bug surfacing — fixes that MySQL exposed:

1. form_submissions.idempotency_key was declared `ulid()` (VARCHAR 26)
   while FormRequest validates `string|max:30`. SQLite ignored the cap;
   MySQL truncated and rejected. Column widened to string(30) to match
   validation.

2. FormFieldValidationRuleService / FormFieldConfigService /
   FormFieldBindingService::snapshotShapesFor — toJsonShape iterated
   collection in DB-default order (insertion-stable on SQLite, undefined
   on MySQL). Schema_snapshot bytes drifted across re-emits, breaking
   audit-replay. Added `->sortBy('id')` (ULID = insertion-order
   semantics, deterministic) on all three.

3. FormSubmissionObserverTest::test_denormalized_indexes_exist queried
   sqlite_master directly. Replaced with the cross-engine
   information_schema.STATISTICS query (the real production check is
   on MySQL anyway).

4. JSON column key order non-determinism: MySQL JSON columns may
   round-trip associative-array keys in a different order than they
   were inserted. assertSame on JSON-derived associative arrays now
   uses assertEquals (structural equality) where the test was previously
   over-asserting on key order:
   - ConditionalLogicActivityLogPayloadTest
   - ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
   - FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
   - FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
   - FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
   - FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
   - FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options

5. Backfill / migration tests (4 classes, 21 tests) ran migrate:rollback
   then migrate inside RefreshDatabase's wrapping transaction. MySQL
   DDL implicit-commits the surrounding transaction, leaving Laravel
   unable to ROLLBACK TO SAVEPOINT at end-of-test (1305 SAVEPOINT
   does not exist). Replaced RefreshDatabase with a per-test
   migrate:fresh in setUp + RefreshDatabaseState::\$migrated = false to
   force the next RefreshDatabase test to re-migrate cleanly:
   - FormFieldBindingMigrationTest
   - ConditionalLogicBackfillTest
   - FormFieldOptionsBackfillTest
   - FormFieldValidationRuleBackfillTest

All 1386 tests now pass on MySQL. Larastan baseline unchanged.

Refs: WS-6 session 2.5 deviation #1 cleanup, RFC-WS-6.md v1.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:10:56 +02:00

329 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\FormBuilder;
use App\Enums\FormBuilder\FormFieldValidationRuleType;
use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormFieldValidationRule;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Owns all writes to `form_field_validation_rules`. Single source of truth
* for:
*
* - rule-type enum + per-rule-type parameter shape enforcement
* - callback-key validation against `config('form_builder.validation_callbacks')`
* (ARCH-FORM-BUILDER §17.4)
* - library → field row-copy on insertFromLibrary (addendum Q3 row-copy mandate)
* - serialisation of rows into the legacy-format flat JSON bag consumed
* by the snapshot writer and API resources
*
* Pattern: one row per (owner, rule_type). An empty specs array on
* `replaceRules()` clears all rules for the owner.
*
* Activity log convention — matches WS-5a: emit
* `field.validation_rules_replaced` on the parent `FormField` only, not on
* `FormFieldLibrary`. Library-level audits live elsewhere.
*/
final class FormFieldValidationRuleService
{
/**
* @return Collection<int, FormFieldValidationRule>
*/
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<array{rule_type:string,parameters?:array<string,mixed>,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<int, FormFieldValidationRule> $rules
* @return array<string, mixed>|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<string, mixed> $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<array<string, mixed>> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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}.",
);
}
}
}