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>
264 lines
10 KiB
PHP
264 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\FormBuilder;
|
|
|
|
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
|
use App\Enums\FormBuilder\FormFieldBindingMode;
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormFieldBinding;
|
|
use App\Models\FormBuilder\FormFieldLibrary;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Owns all writes to `form_field_bindings`. Single source of truth for:
|
|
* - entity-column registry validation (config/form_binding.php, ARCH §6.2)
|
|
* - library → field row-copy on insertFromLibrary (Q3 row-copy mandate)
|
|
* - serialisation of rows into the ARCH §6.3 JSON shape (for snapshot
|
|
* embedding and API resource output)
|
|
*
|
|
* Pattern B (no binding) is represented by the absence of a row — callers
|
|
* pass an empty array to replaceBindings() to clear bindings.
|
|
*
|
|
* Danger guards (pre-existing in FormFieldService::update for owner=field)
|
|
* sit above this service; replaceBindings() trusts the guard upstream and
|
|
* only enforces registry validation + transactional write.
|
|
*/
|
|
final class FormFieldBindingService
|
|
{
|
|
/**
|
|
* @return Collection<int, FormFieldBinding>
|
|
*/
|
|
public function bindingsFor(FormField|FormFieldLibrary $owner): Collection
|
|
{
|
|
$type = $this->ownerTypeFor($owner);
|
|
|
|
return FormFieldBinding::query()
|
|
->where('owner_type', $type)
|
|
->where('owner_id', $owner->getKey())
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Replace the set of bindings on an owner transactionally. Callers pass
|
|
* an array of binding specs; each spec is validated against the entity-
|
|
* column registry before anything is written. An empty array clears all
|
|
* bindings for the owner (Pattern B).
|
|
*
|
|
* @param list<array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string,merge_strategy?:string,trust_level?:int,is_identity_key?:bool}> $bindingData
|
|
*/
|
|
public function replaceBindings(FormField|FormFieldLibrary $owner, array $bindingData): void
|
|
{
|
|
foreach ($bindingData as $spec) {
|
|
$this->assertSpecValid($spec);
|
|
}
|
|
|
|
$ownerType = $this->ownerTypeFor($owner);
|
|
|
|
DB::transaction(function () use ($owner, $ownerType, $bindingData): void {
|
|
FormFieldBinding::query()
|
|
->withoutGlobalScopes()
|
|
->where('owner_type', $ownerType)
|
|
->where('owner_id', $owner->getKey())
|
|
->delete();
|
|
|
|
foreach ($bindingData as $spec) {
|
|
FormFieldBinding::query()->withoutGlobalScopes()->create([
|
|
'owner_type' => $ownerType,
|
|
'owner_id' => $owner->getKey(),
|
|
'target_entity' => $spec['target_entity'],
|
|
'target_attribute' => $spec['target_attribute'],
|
|
'mode' => $spec['mode'],
|
|
'sync_direction' => $spec['sync_direction'] ?? null,
|
|
'merge_strategy' => $spec['merge_strategy']
|
|
?? FormFieldBindingMergeStrategy::Overwrite->value,
|
|
'trust_level' => $spec['trust_level'] ?? 50,
|
|
'is_identity_key' => $spec['is_identity_key'] ?? false,
|
|
]);
|
|
}
|
|
|
|
if ($owner instanceof FormField) {
|
|
$owner->logFieldChange('field.bindings_replaced', [
|
|
'count' => count($bindingData),
|
|
]);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Row-copy from a library entry to a freshly-inserted field (ARCH §6.7,
|
|
* addendum Q3). Preserves every binding column; only owner_type /
|
|
* owner_id are rewritten.
|
|
*/
|
|
public function copyBindings(FormFieldLibrary $from, FormField $to): void
|
|
{
|
|
$bindings = $this->bindingsFor($from);
|
|
|
|
if ($bindings->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($bindings, $to): void {
|
|
foreach ($bindings as $binding) {
|
|
FormFieldBinding::query()->withoutGlobalScopes()->create([
|
|
'owner_type' => 'form_field',
|
|
'owner_id' => $to->id,
|
|
'target_entity' => $binding->target_entity,
|
|
'target_attribute' => $binding->target_attribute,
|
|
'mode' => $binding->mode instanceof FormFieldBindingMode
|
|
? $binding->mode->value
|
|
: (string) $binding->mode,
|
|
'sync_direction' => $binding->sync_direction,
|
|
'merge_strategy' => $binding->merge_strategy instanceof FormFieldBindingMergeStrategy
|
|
? $binding->merge_strategy->value
|
|
: (string) $binding->merge_strategy,
|
|
'trust_level' => (int) $binding->trust_level,
|
|
'is_identity_key' => (bool) $binding->is_identity_key,
|
|
]);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serialise a binding row into the ARCH §6.3 JSON shape. Returned null
|
|
* if no binding is given — callers can pipe directly into snapshot /
|
|
* resource output (Pattern B = null).
|
|
*
|
|
* @return array{mode:string,entity:string,column:string,sync_direction?:string}|null
|
|
*/
|
|
public function toJsonShape(?FormFieldBinding $binding): ?array
|
|
{
|
|
if ($binding === null) {
|
|
return null;
|
|
}
|
|
|
|
$mode = $binding->mode instanceof FormFieldBindingMode
|
|
? $binding->mode->value
|
|
: (string) $binding->mode;
|
|
|
|
$shape = [
|
|
'mode' => $mode,
|
|
'entity' => $binding->target_entity,
|
|
'column' => $binding->target_attribute,
|
|
];
|
|
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
|
|
$shape['sync_direction'] = $binding->sync_direction;
|
|
}
|
|
|
|
return $shape;
|
|
}
|
|
|
|
/**
|
|
* Richer snapshot shape for the WS-6 binding pipeline. Captures
|
|
* applicator-relevant metadata (binding id, merge_strategy,
|
|
* trust_level, is_identity_key) on top of the legacy
|
|
* mode/entity/column triple.
|
|
*
|
|
* RFC-WS-6.md §3 (Q6): the applicator reads from snapshot, not from
|
|
* the live form_field_bindings table — this shape carries everything
|
|
* needed for conflict resolution and person provisioning.
|
|
*
|
|
* @return array{
|
|
* id:string,
|
|
* mode:string,
|
|
* entity:string,
|
|
* column:string,
|
|
* sync_direction?:string,
|
|
* merge_strategy:string,
|
|
* trust_level:int,
|
|
* is_identity_key:bool,
|
|
* }
|
|
*/
|
|
public function toApplicatorShape(FormFieldBinding $binding): array
|
|
{
|
|
// FormFieldBinding casts mode/merge_strategy to enum already; access
|
|
// the value directly without redundant instanceof guards.
|
|
$shape = [
|
|
'id' => (string) $binding->id,
|
|
'mode' => $binding->mode->value,
|
|
'entity' => (string) $binding->target_entity,
|
|
'column' => (string) $binding->target_attribute,
|
|
'merge_strategy' => ($binding->merge_strategy ?? FormFieldBindingMergeStrategy::Overwrite)->value,
|
|
'trust_level' => (int) $binding->trust_level,
|
|
'is_identity_key' => (bool) $binding->is_identity_key,
|
|
];
|
|
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
|
|
$shape['sync_direction'] = (string) $binding->sync_direction;
|
|
}
|
|
|
|
return $shape;
|
|
}
|
|
|
|
/**
|
|
* Build the snapshot fragment for the WS-6 `bindings` (plural) key.
|
|
* Session 2.5 dropped the legacy `binding` (singular) — see RFC v1.1
|
|
* + WS-6 session 2 deviation #9. With no production data and dev
|
|
* seeders re-running every cycle, dual-key state had no upside.
|
|
*
|
|
* @param iterable<FormFieldBinding> $bindings
|
|
* @return array{bindings: list<array<string, mixed>>}
|
|
*/
|
|
public function snapshotShapesFor(iterable $bindings): array
|
|
{
|
|
// 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.
|
|
$sorted = collect($bindings)->sortBy(fn (FormFieldBinding $b) => (string) $b->id);
|
|
$all = [];
|
|
foreach ($sorted as $binding) {
|
|
$all[] = $this->toApplicatorShape($binding);
|
|
}
|
|
|
|
return ['bindings' => $all];
|
|
}
|
|
|
|
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
|
|
{
|
|
$entity = (string) ($spec['target_entity'] ?? '');
|
|
$attribute = (string) ($spec['target_attribute'] ?? '');
|
|
$mode = (string) ($spec['mode'] ?? '');
|
|
|
|
if ($entity === '' || $attribute === '') {
|
|
throw new \InvalidArgumentException(
|
|
'Binding spec requires target_entity and target_attribute.',
|
|
);
|
|
}
|
|
|
|
if (FormFieldBindingMode::tryFrom($mode) === null) {
|
|
throw new \InvalidArgumentException(
|
|
"Binding spec mode '{$mode}' is not a valid FormFieldBindingMode.",
|
|
);
|
|
}
|
|
|
|
if (array_key_exists('merge_strategy', $spec)) {
|
|
$strategy = (string) $spec['merge_strategy'];
|
|
if (FormFieldBindingMergeStrategy::tryFrom($strategy) === null) {
|
|
throw new \InvalidArgumentException(
|
|
"Binding spec merge_strategy '{$strategy}' is not a valid FormFieldBindingMergeStrategy.",
|
|
);
|
|
}
|
|
}
|
|
|
|
$registry = (array) config('form_binding.'.$entity);
|
|
if ($registry === []) {
|
|
throw new \InvalidArgumentException(
|
|
"Binding target_entity '{$entity}' is not registered in config/form_binding.php.",
|
|
);
|
|
}
|
|
|
|
if (! array_key_exists($attribute, $registry)) {
|
|
throw new \InvalidArgumentException(
|
|
"Binding target_attribute '{$entity}.{$attribute}' is not registered in config/form_binding.php.",
|
|
);
|
|
}
|
|
}
|
|
}
|