From 9fa8231cf7a128933ad4635cd069bccf100aed27 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 04:47:30 +0200 Subject: [PATCH] refactor(form-field): extract FormFieldChildTableMorphScope abstract base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the WS-5 family follow-up tracked as FORM-BUILDER-MORPH-SCOPE-BASE-CLASS in BACKLOG.md. Per addendum §Q3 Uitvoering across WS-5a/b/c/d, base-class extraction was deliberately deferred until all four concrete morph-scope siblings existed and the "what actually varies" question could be answered empirically. The answer is: nothing. All four siblings — FormFieldBindingScope (WS-5a), FormFieldValidationRuleScope (WS-5b), FormFieldConfigScope (WS-5b commit 5), and FormFieldOptionScope (WS-5d) — are byte-equal in their apply() and resolveOrganisationId() methods (Phase A diff verification clean: zero lines diverging across all three pairwise comparisons). Approach: - New abstract class FormFieldChildTableMorphScope holds the full UNION-over-two-owner-chains scope logic with the morph alias literals extracted as private constants (OWNER_TYPE_FIELD, OWNER_TYPE_LIBRARY) for one-location-of-truth. - The four concrete scopes become marker subclasses (`final class X extends FormFieldChildTableMorphScope {}`) — class identity preserved so every existing `withoutGlobalScope(FormFieldXScope::class)` call site in cascade observers, backfill migrations, and platform super_admin paths continues to work unchanged. The 4 test call sites (in the four *ScopeTest classes) work without modification. - Helper visibility stays `private` per YAGNI. If a future sibling needs to vary the morph aliases or owner-chain, the helpers promote to `protected` at that point. - Stylistic refinement vs. the four originals: `Organisation` and `Event` in resolveOrganisationId() now use `use` statements at the top of the file instead of inline `\App\Models\…` FQNs. Net diff: Pre: 4 concrete scope files at ~106 lines each (~424 lines total) Post: 4 marker subclasses at 20 lines (80 total) + 1 abstract base at 125 lines = 205 lines total Saving: ~219 lines of duplication removed. Tests: 1208 passed (3260 assertions) → 1208 passed (3260 assertions). Identical — public behaviour unchanged. Larastan: clean (no new errors beyond baseline). Rector: 357 → 355 dry-run suggestions (small reduction from the deduplication; no apply in this commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/Scopes/FormFieldBindingScope.php | 106 ++------------- .../Scopes/FormFieldChildTableMorphScope.php | 125 ++++++++++++++++++ .../Models/Scopes/FormFieldConfigScope.php | 102 ++------------ .../Models/Scopes/FormFieldOptionScope.php | 107 ++------------- .../Scopes/FormFieldValidationRuleScope.php | 102 ++------------ 5 files changed, 165 insertions(+), 377 deletions(-) create mode 100644 api/app/Models/Scopes/FormFieldChildTableMorphScope.php diff --git a/api/app/Models/Scopes/FormFieldBindingScope.php b/api/app/Models/Scopes/FormFieldBindingScope.php index 3bfc90de..35413019 100644 --- a/api/app/Models/Scopes/FormFieldBindingScope.php +++ b/api/app/Models/Scopes/FormFieldBindingScope.php @@ -4,103 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Multi-tenant isolation for `form_field_bindings`. The table has a - * polymorphic owner that points at either `form_field` or - * `form_field_library`; `OrganisationScope` (Q2 FK-chain resolver) can't - * walk a morph parent, so this scope does the equivalent UNION walk: + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * owner_id ∈ ( - * SELECT id FROM form_fields - * WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?) - * UNION - * SELECT id FROM form_field_library - * WHERE organisation_id = ? - * ) + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldBindingScope::class)` call sites in + * cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * Organisation context resolution mirrors `OrganisationScope` — explicit - * override via constructor, then the `organisation` / `event` route - * parameter fallbacks. CLI, queues, and unauthenticated flows skip the - * scope (consistent with OrganisationScope). - * - * Escape hatch: callers that need cross-tenant reads use - * `FormFieldBinding::withoutGlobalScope(FormFieldBindingScope::class)`. + * History: WS-5a; ARCH-FORM-BUILDER §6.7 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldBindingScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->resolveOrganisationId(); - if ($orgId === null) { - return; - } - - $fieldIds = FormField::query() - ->withoutGlobalScope(OrganisationScope::class) - ->whereIn( - 'form_schema_id', - FormSchema::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'), - ) - ->select('id'); - - $libraryIds = FormFieldLibrary::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'); - - $table = $model->getTable(); - - $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { - $outer->where(function (Builder $q) use ($table, $fieldIds): void { - $q->where("$table.owner_type", 'form_field') - ->whereIn("$table.owner_id", $fieldIds); - })->orWhere(function (Builder $q) use ($table, $libraryIds): void { - $q->where("$table.owner_type", 'form_field_library') - ->whereIn("$table.owner_id", $libraryIds); - }); - }); - } - - private function resolveOrganisationId(): ?string - { - if ($this->organisationId !== null) { - return $this->organisationId; - } - - $route = request()->route(); - if ($route === null) { - return null; - } - - $org = $route->parameter('organisation'); - - if ($org instanceof \App\Models\Organisation) { - return $org->id; - } - - if (is_string($org) && $org !== '') { - return $org; - } - - $event = $route->parameter('event'); - if ($event instanceof \App\Models\Event) { - return $event->organisation_id; - } - - return null; - } -} +final class FormFieldBindingScope extends FormFieldChildTableMorphScope {} diff --git a/api/app/Models/Scopes/FormFieldChildTableMorphScope.php b/api/app/Models/Scopes/FormFieldChildTableMorphScope.php new file mode 100644 index 00000000..38d490d6 --- /dev/null +++ b/api/app/Models/Scopes/FormFieldChildTableMorphScope.php @@ -0,0 +1,125 @@ +resolveOrganisationId(); + if ($orgId === null) { + return; + } + + $fieldIds = FormField::query() + ->withoutGlobalScope(OrganisationScope::class) + ->whereIn( + 'form_schema_id', + FormSchema::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'), + ) + ->select('id'); + + $libraryIds = FormFieldLibrary::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'); + + $table = $model->getTable(); + + $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { + $outer->where(function (Builder $q) use ($table, $fieldIds): void { + $q->where("$table.owner_type", self::OWNER_TYPE_FIELD) + ->whereIn("$table.owner_id", $fieldIds); + })->orWhere(function (Builder $q) use ($table, $libraryIds): void { + $q->where("$table.owner_type", self::OWNER_TYPE_LIBRARY) + ->whereIn("$table.owner_id", $libraryIds); + }); + }); + } + + private function resolveOrganisationId(): ?string + { + if ($this->organisationId !== null) { + return $this->organisationId; + } + + $route = request()->route(); + if ($route === null) { + return null; + } + + $org = $route->parameter('organisation'); + + if ($org instanceof Organisation) { + return $org->id; + } + + if (is_string($org) && $org !== '') { + return $org; + } + + $event = $route->parameter('event'); + if ($event instanceof Event) { + return $event->organisation_id; + } + + return null; + } +} diff --git a/api/app/Models/Scopes/FormFieldConfigScope.php b/api/app/Models/Scopes/FormFieldConfigScope.php index ab07a783..d868b10d 100644 --- a/api/app/Models/Scopes/FormFieldConfigScope.php +++ b/api/app/Models/Scopes/FormFieldConfigScope.php @@ -4,99 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Third sibling in the form-field-child-table scope family, after - * `FormFieldBindingScope` (WS-5a) and `FormFieldValidationRuleScope` - * (WS-5b commit 1). Identical UNION-over-two-owner-chains shape: + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * owner_id ∈ ( - * SELECT id FROM form_fields - * WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?) - * UNION - * SELECT id FROM form_field_library - * WHERE organisation_id = ? - * ) + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldConfigScope::class)` call sites in + * cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * Base-class extraction between the three siblings is deliberately - * deferred to WS-5d (where `form_field_options` lands and the fourth - * concrete implementation may clarify what truly varies). Premature - * abstraction from three is still premature. + * History: WS-5b commit 5; ARCH-FORM-BUILDER §17.5.3 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldConfigScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->resolveOrganisationId(); - if ($orgId === null) { - return; - } - - $fieldIds = FormField::query() - ->withoutGlobalScope(OrganisationScope::class) - ->whereIn( - 'form_schema_id', - FormSchema::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'), - ) - ->select('id'); - - $libraryIds = FormFieldLibrary::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'); - - $table = $model->getTable(); - - $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { - $outer->where(function (Builder $q) use ($table, $fieldIds): void { - $q->where("$table.owner_type", 'form_field') - ->whereIn("$table.owner_id", $fieldIds); - })->orWhere(function (Builder $q) use ($table, $libraryIds): void { - $q->where("$table.owner_type", 'form_field_library') - ->whereIn("$table.owner_id", $libraryIds); - }); - }); - } - - private function resolveOrganisationId(): ?string - { - if ($this->organisationId !== null) { - return $this->organisationId; - } - - $route = request()->route(); - if ($route === null) { - return null; - } - - $org = $route->parameter('organisation'); - - if ($org instanceof \App\Models\Organisation) { - return $org->id; - } - - if (is_string($org) && $org !== '') { - return $org; - } - - $event = $route->parameter('event'); - if ($event instanceof \App\Models\Event) { - return $event->organisation_id; - } - - return null; - } -} +final class FormFieldConfigScope extends FormFieldChildTableMorphScope {} diff --git a/api/app/Models/Scopes/FormFieldOptionScope.php b/api/app/Models/Scopes/FormFieldOptionScope.php index c7be15d4..9f397d4c 100644 --- a/api/app/Models/Scopes/FormFieldOptionScope.php +++ b/api/app/Models/Scopes/FormFieldOptionScope.php @@ -4,104 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Fourth and final sibling in the form-field-child-table scope family, - * after `FormFieldBindingScope` (WS-5a), - * `FormFieldValidationRuleScope` and `FormFieldConfigScope` (WS-5b). + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * Identical UNION-over-two-owner-chains shape: + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldOptionScope::class)` call sites in + * cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * owner_id ∈ ( - * SELECT id FROM form_fields - * WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?) - * UNION - * SELECT id FROM form_field_library - * WHERE organisation_id = ? - * ) - * - * Now that all four concrete implementations exist, base-class - * extraction across the family is the logical follow-up — deferred to - * a separate work package per addendum §17.4.3 / §17.5.3 / §17.6.3. - * - * Escape hatch: `FormFieldOption::withoutGlobalScope(FormFieldOptionScope::class)` - * for cascade observers, backfill migrations, and platform super_admin - * paths. + * History: WS-5d; ARCH-FORM-BUILDER §17.6.3 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldOptionScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->resolveOrganisationId(); - if ($orgId === null) { - return; - } - - $fieldIds = FormField::query() - ->withoutGlobalScope(OrganisationScope::class) - ->whereIn( - 'form_schema_id', - FormSchema::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'), - ) - ->select('id'); - - $libraryIds = FormFieldLibrary::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'); - - $table = $model->getTable(); - - $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { - $outer->where(function (Builder $q) use ($table, $fieldIds): void { - $q->where("$table.owner_type", 'form_field') - ->whereIn("$table.owner_id", $fieldIds); - })->orWhere(function (Builder $q) use ($table, $libraryIds): void { - $q->where("$table.owner_type", 'form_field_library') - ->whereIn("$table.owner_id", $libraryIds); - }); - }); - } - - private function resolveOrganisationId(): ?string - { - if ($this->organisationId !== null) { - return $this->organisationId; - } - - $route = request()->route(); - if ($route === null) { - return null; - } - - $org = $route->parameter('organisation'); - - if ($org instanceof \App\Models\Organisation) { - return $org->id; - } - - if (is_string($org) && $org !== '') { - return $org; - } - - $event = $route->parameter('event'); - if ($event instanceof \App\Models\Event) { - return $event->organisation_id; - } - - return null; - } -} +final class FormFieldOptionScope extends FormFieldChildTableMorphScope {} diff --git a/api/app/Models/Scopes/FormFieldValidationRuleScope.php b/api/app/Models/Scopes/FormFieldValidationRuleScope.php index 61a66201..9c304efa 100644 --- a/api/app/Models/Scopes/FormFieldValidationRuleScope.php +++ b/api/app/Models/Scopes/FormFieldValidationRuleScope.php @@ -4,99 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Multi-tenant isolation for `form_field_validation_rules`. Sibling to - * `FormFieldBindingScope` — the two share the same UNION shape over the - * polymorphic owner's two possible parents (`form_field → form_schema → - * organisation_id` ∪ `form_field_library → organisation_id`). + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * Duplicate code with `FormFieldBindingScope` is acknowledged; base-class - * extraction is deferred to WS-5d per the architect addendum Q3 decision: - * premature abstraction from two is still premature, and WS-5d adds a - * third sibling that will make what truly varies visible. + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldValidationRuleScope::class)` call sites + * in cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * Organisation context resolution mirrors `OrganisationScope` — explicit - * override via constructor, then `organisation` / `event` route parameter - * fallbacks. CLI, queues, and unauthenticated flows skip the scope. - * - * Escape hatch: - * `FormFieldValidationRule::withoutGlobalScope(FormFieldValidationRuleScope::class)`. + * History: WS-5b; ARCH-FORM-BUILDER §17.4.3 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldValidationRuleScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->resolveOrganisationId(); - if ($orgId === null) { - return; - } - - $fieldIds = FormField::query() - ->withoutGlobalScope(OrganisationScope::class) - ->whereIn( - 'form_schema_id', - FormSchema::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'), - ) - ->select('id'); - - $libraryIds = FormFieldLibrary::query() - ->withoutGlobalScope(OrganisationScope::class) - ->where('organisation_id', $orgId) - ->select('id'); - - $table = $model->getTable(); - - $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { - $outer->where(function (Builder $q) use ($table, $fieldIds): void { - $q->where("$table.owner_type", 'form_field') - ->whereIn("$table.owner_id", $fieldIds); - })->orWhere(function (Builder $q) use ($table, $libraryIds): void { - $q->where("$table.owner_type", 'form_field_library') - ->whereIn("$table.owner_id", $libraryIds); - }); - }); - } - - private function resolveOrganisationId(): ?string - { - if ($this->organisationId !== null) { - return $this->organisationId; - } - - $route = request()->route(); - if ($route === null) { - return null; - } - - $org = $route->parameter('organisation'); - - if ($org instanceof \App\Models\Organisation) { - return $org->id; - } - - if (is_string($org) && $org !== '') { - return $org; - } - - $event = $route->parameter('event'); - if ($event instanceof \App\Models\Event) { - return $event->organisation_id; - } - - return null; - } -} +final class FormFieldValidationRuleScope extends FormFieldChildTableMorphScope {}