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>
191 lines
6.7 KiB
PHP
191 lines
6.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\FormBuilder;
|
|
|
|
use App\Enums\FormBuilder\FormFieldConfigType;
|
|
use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException;
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormFieldConfig;
|
|
use App\Models\FormBuilder\FormFieldLibrary;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Owns writes to `form_field_configs` — non-validation per-field
|
|
* configuration (ARCH-FORM-BUILDER §17.5; addendum Q3 WS-5b Uitvoering).
|
|
*
|
|
* Mirrors `FormFieldValidationRuleService` exactly: same service-layer
|
|
* contract (`configsFor`, `replaceConfigs`, `copyConfigs`,
|
|
* `toJsonShape`, `assertSpecsValid`), same activity-log convention
|
|
* (emit `field.configs_replaced` on FormField only, silent for library
|
|
* — matches §6.7 WS-5a pattern).
|
|
*
|
|
* Re-uses the `UnknownValidationRuleTypeException` for parameter-shape
|
|
* violations; the two services share a failure mode (caller supplied a
|
|
* spec that does not match the registered type's schema) and adding a
|
|
* second exception class for the same semantic would be noise.
|
|
*/
|
|
final class FormFieldConfigService
|
|
{
|
|
/**
|
|
* @return Collection<int, FormFieldConfig>
|
|
*/
|
|
public function configsFor(FormField|FormFieldLibrary $owner): Collection
|
|
{
|
|
$type = $this->ownerTypeFor($owner);
|
|
|
|
return FormFieldConfig::query()
|
|
->where('owner_type', $type)
|
|
->where('owner_id', $owner->getKey())
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @param list<array{config_type:string,parameters?:array<string,mixed>}> $specs
|
|
*/
|
|
public function replaceConfigs(FormField|FormFieldLibrary $owner, array $specs): void
|
|
{
|
|
$this->assertSpecsValid($specs);
|
|
|
|
$ownerType = $this->ownerTypeFor($owner);
|
|
|
|
DB::transaction(function () use ($owner, $ownerType, $specs): void {
|
|
FormFieldConfig::query()
|
|
->withoutGlobalScopes()
|
|
->where('owner_type', $ownerType)
|
|
->where('owner_id', $owner->getKey())
|
|
->delete();
|
|
|
|
foreach ($specs as $spec) {
|
|
FormFieldConfig::query()->withoutGlobalScopes()->create([
|
|
'owner_type' => $ownerType,
|
|
'owner_id' => $owner->getKey(),
|
|
'config_type' => $spec['config_type'],
|
|
'parameters' => $spec['parameters'] ?? [],
|
|
]);
|
|
}
|
|
|
|
if ($owner instanceof FormField) {
|
|
$owner->logFieldChange('field.configs_replaced', [
|
|
'count' => count($specs),
|
|
]);
|
|
}
|
|
});
|
|
}
|
|
|
|
public function copyConfigs(FormFieldLibrary $from, FormField $to): void
|
|
{
|
|
$configs = $this->configsFor($from);
|
|
|
|
if ($configs->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($configs, $to): void {
|
|
foreach ($configs as $config) {
|
|
FormFieldConfig::query()->withoutGlobalScopes()->create([
|
|
'owner_type' => 'form_field',
|
|
'owner_id' => $to->id,
|
|
'config_type' => $config->config_type instanceof FormFieldConfigType
|
|
? $config->config_type->value
|
|
: (string) $config->config_type,
|
|
'parameters' => (array) $config->parameters,
|
|
]);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serialise a config collection into the nested-object JSON shape
|
|
* consumed by snapshot writer and API resources. Returns `null` on
|
|
* empty (matches the contract pattern WS-5b introduced on the
|
|
* validation-rules service).
|
|
*
|
|
* Shape per config_type:
|
|
* - tag_categories → `{"categories": [string]}`
|
|
* - storage_disk → `{"disk": string}`
|
|
*
|
|
* The external envelope is `{<config_type>: <parameters>}`:
|
|
* `{"tag_categories": {"categories": ["Veiligheid"]},
|
|
* "storage_disk": {"disk": "local"}}`
|
|
*
|
|
* @param Collection<int, FormFieldConfig> $configs
|
|
* @return array<string, array<string, mixed>>|null
|
|
*/
|
|
public function toJsonShape(Collection $configs): ?array
|
|
{
|
|
if ($configs->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 ($configs->sortBy('id') as $config) {
|
|
$type = $config->config_type instanceof FormFieldConfigType
|
|
? $config->config_type->value
|
|
: (string) $config->config_type;
|
|
$out[$type] = (array) $config->parameters;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/** @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
|
|
{
|
|
$raw = (string) ($spec['config_type'] ?? '');
|
|
$enum = FormFieldConfigType::tryFrom($raw);
|
|
if ($enum === null) {
|
|
throw new UnknownValidationRuleTypeException(
|
|
"Config config_type '{$raw}' is not a registered FormFieldConfigType case.",
|
|
);
|
|
}
|
|
|
|
$params = (array) ($spec['parameters'] ?? []);
|
|
|
|
switch ($enum) {
|
|
case FormFieldConfigType::TagCategories:
|
|
if (! isset($params['categories']) || ! is_array($params['categories'])) {
|
|
throw new UnknownValidationRuleTypeException(
|
|
"Config 'tag_categories' requires parameters.categories (array of strings).",
|
|
);
|
|
}
|
|
foreach ($params['categories'] as $cat) {
|
|
if (! is_string($cat) || $cat === '') {
|
|
throw new UnknownValidationRuleTypeException(
|
|
"Config 'tag_categories' parameters.categories must be non-empty strings.",
|
|
);
|
|
}
|
|
}
|
|
|
|
return;
|
|
|
|
case FormFieldConfigType::StorageDisk:
|
|
if (! isset($params['disk']) || ! is_string($params['disk']) || $params['disk'] === '') {
|
|
throw new UnknownValidationRuleTypeException(
|
|
"Config 'storage_disk' requires non-empty string parameters.disk.",
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|