Skip to content

Commit fcd2b99

Browse files
Merge pull request #198 from relaticle/feat/section-scoped-custom-field-resolution
feat: section-scoped custom field resolution
2 parents ae65a03 + 5fa87da commit fcd2b99

22 files changed

Lines changed: 1411 additions & 36 deletions
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Migrations\Migration;
6+
use Illuminate\Database\Schema\Blueprint;
7+
use Illuminate\Support\Facades\DB;
8+
use Illuminate\Support\Facades\Schema;
9+
use Relaticle\CustomFields\Enums\CustomFieldsFeature;
10+
use Relaticle\CustomFields\FeatureSystem\FeatureManager;
11+
12+
/*
13+
* `onlySections()` (see BaseBuilder::onlySections()) lets consumers that version their
14+
* form definitions scope custom-field resolution by section id instead of by code, so a
15+
* cloned section can carry a field whose code already exists on its sibling section. The
16+
* unique key created by create_custom_fields_table.php — (code, entity_type[, tenant]) —
17+
* blocks exactly that data shape at the database level, so any consumer of onlySections()
18+
* would fail to save the second field unless this key is widened to also include
19+
* custom_field_section_id.
20+
*
21+
* custom_field_sections is deliberately left untouched: onlySections() scopes by section
22+
* id, never by section code, so nothing here requires sections to share a code.
23+
*
24+
* custom_field_section_id is nullable, and both MySQL and Postgres treat NULL as distinct
25+
* in a unique index — including within a composite one. So after this migration, two rows
26+
* that both have custom_field_section_id IS NULL can still share (code, entity_type[,
27+
* tenant]): the wide key does not constrain them, because NULL never equals NULL for
28+
* uniqueness purposes. That's a protection existing installs have today (every row is
29+
* globally unique per entity type) and silently lose once this ships. There is no schema
30+
* workaround for this — a consumer that needs collision protection for sectionless fields
31+
* must keep them out of this data shape or enforce it at the application layer.
32+
*/
33+
return new class extends Migration
34+
{
35+
public function up(): void
36+
{
37+
$this->swapUniqueKey(
38+
from: $this->narrowColumns(),
39+
fromIndexName: null,
40+
to: $this->wideColumns(),
41+
toIndexName: $this->wideIndexName(),
42+
);
43+
}
44+
45+
public function down(): void
46+
{
47+
$this->assertNoDuplicatesUnderNarrowKey();
48+
49+
$this->swapUniqueKey(
50+
from: $this->wideColumns(),
51+
fromIndexName: $this->wideIndexName(),
52+
to: $this->narrowColumns(),
53+
toIndexName: null,
54+
);
55+
}
56+
57+
/**
58+
* MySQL runs each ALTER TABLE as its own auto-committing DDL statement, so dropping
59+
* the wide key and adding the narrow one are not transactional together. If rows exist
60+
* that share (code, entity_type[, tenant]) across different sections — exactly the
61+
* shape the wide key exists to allow — the DROP succeeds and the subsequent ADD fails
62+
* on the duplicate, leaving the table with neither unique key. Check first and abort
63+
* before touching anything.
64+
*/
65+
private function assertNoDuplicatesUnderNarrowKey(): void
66+
{
67+
$table = config('custom-fields.database.table_names.custom_fields');
68+
69+
if (! Schema::hasColumn($table, 'custom_field_section_id')) {
70+
return;
71+
}
72+
73+
$columns = $this->narrowColumns();
74+
75+
$duplicateCodes = DB::table($table)
76+
->select($columns)
77+
->groupBy($columns)
78+
->havingRaw('count(*) > 1')
79+
->pluck('code');
80+
81+
if ($duplicateCodes->isEmpty()) {
82+
return;
83+
}
84+
85+
throw new RuntimeException(sprintf(
86+
'Cannot roll back the custom_fields unique key: %d code(s) — including "%s" — are shared by more than one row for the same (%s), only differing by custom_field_section_id. onlySections() allows this under the wide key, but the narrow key being restored cannot. Resolve or remove the duplicate rows before rolling back this migration.',
87+
$duplicateCodes->count(),
88+
$duplicateCodes->first(),
89+
implode(', ', $columns)
90+
));
91+
}
92+
93+
/**
94+
* Drops $from's unique key if present and adds $to's if absent. Shared by both
95+
* directions: up() widens (code, entity_type[, tenant]) to also include
96+
* custom_field_section_id; down() narrows it back to the original key.
97+
*
98+
* @param array<int, string> $from
99+
* @param array<int, string> $to
100+
*/
101+
private function swapUniqueKey(array $from, ?string $fromIndexName, array $to, ?string $toIndexName): void
102+
{
103+
$table = config('custom-fields.database.table_names.custom_fields');
104+
105+
if (! Schema::hasColumn($table, 'custom_field_section_id')) {
106+
return;
107+
}
108+
109+
$fromIndexName ??= $this->defaultUniqueIndexName($table, $from);
110+
$toIndexName ??= $this->defaultUniqueIndexName($table, $to);
111+
$existingIndexes = collect(Schema::getIndexes($table))->pluck('name');
112+
113+
Schema::table($table, function (Blueprint $blueprint) use ($existingIndexes, $fromIndexName, $to, $toIndexName): void {
114+
if ($existingIndexes->contains($fromIndexName)) {
115+
$blueprint->dropUnique($fromIndexName);
116+
}
117+
118+
if (! $existingIndexes->contains($toIndexName)) {
119+
$blueprint->unique($to, $toIndexName);
120+
}
121+
});
122+
}
123+
124+
/**
125+
* @return array<int, string>
126+
*/
127+
private function narrowColumns(): array
128+
{
129+
$columns = ['code', 'entity_type'];
130+
131+
if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) {
132+
$columns[] = config('custom-fields.database.column_names.tenant_foreign_key');
133+
}
134+
135+
return $columns;
136+
}
137+
138+
/**
139+
* @return array<int, string>
140+
*/
141+
private function wideColumns(): array
142+
{
143+
return [...$this->narrowColumns(), 'custom_field_section_id'];
144+
}
145+
146+
private function wideIndexName(): string
147+
{
148+
return FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)
149+
? 'cf_code_entity_tenant_section_unique'
150+
: 'cf_code_entity_section_unique';
151+
}
152+
153+
/**
154+
* Mirrors Laravel's own auto-generated unique-index name (Blueprint::createIndexName())
155+
* so the drop target matches exactly what create_custom_fields_table.php produced,
156+
* without hardcoding a name that would drift if a configured column name changes.
157+
*
158+
* Laravel's shipped config/database.php enables `prefix_indexes` for mysql and pgsql,
159+
* which makes Blueprint::createIndexName() fold the connection's table prefix into the
160+
* name it generates. Skipping that step here would compute a drop target that never
161+
* matches the real index name on a prefixed install, so the drop would silently no-op.
162+
*
163+
* @param array<int, string> $columns
164+
*/
165+
private function defaultUniqueIndexName(string $table, array $columns): string
166+
{
167+
$connection = Schema::getConnection();
168+
169+
$prefixedTable = $connection->getConfig('prefix_indexes')
170+
? $this->applyTablePrefix($table, $connection->getTablePrefix())
171+
: $table;
172+
173+
$index = strtolower($prefixedTable.'_'.implode('_', $columns).'_unique');
174+
175+
return str_replace(['-', '.'], '_', $index);
176+
}
177+
178+
private function applyTablePrefix(string $table, string $prefix): string
179+
{
180+
return str_contains($table, '.')
181+
? substr_replace($table, '.'.$prefix, strrpos($table, '.'), 1)
182+
: $prefix.$table;
183+
}
184+
};

docs/content/1.getting-started/1.installation.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,18 @@ php artisan vendor:publish --tag="custom-fields-translations"
8484
```bash
8585
php artisan vendor:publish --tag="custom-fields-views"
8686
```
87+
88+
## Picking Up New Migrations After an Upgrade
89+
90+
Package migrations are not run automatically by `php artisan migrate` after a version
91+
bump — they're only copied into your app the first time you install, or when you
92+
explicitly republish them. If a release adds or changes a migration, republish and run
93+
it:
94+
95+
```bash
96+
php artisan vendor:publish --tag="custom-fields-migrations"
97+
php artisan migrate
98+
```
99+
100+
See the [upgrade guide](/getting-started/upgrade-guide#picking-up-new-migrations) for a
101+
concrete example of a release that requires this step.

docs/content/1.getting-started/3.upgrade-guide.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,35 @@ php artisan custom-fields:upgrade --skip=clear-caches
4545
php artisan custom-fields:upgrade --skip=email-format,phone-format
4646
```
4747

48+
## Picking Up New Migrations
49+
50+
Package migrations are not run automatically by `php artisan migrate` after a version
51+
bump — they're only copied into your app on first install, or when you explicitly
52+
republish them:
53+
54+
```bash
55+
php artisan vendor:publish --tag="custom-fields-migrations"
56+
php artisan migrate
57+
```
58+
59+
For example, a release may relax the `custom_fields` unique key from
60+
`(code, entity_type[, tenant])` to `(code, entity_type[, tenant], custom_field_section_id)`
61+
so a field code can be reused across different sections — the shape `onlySections()` (see
62+
[Builder Scoping](/essentials/builder-scoping)) relies on. Existing installs need the
63+
republish-and-migrate step above to pick that change up; it is not applied automatically.
64+
65+
**Before rolling that migration back**, resolve any rows that ended up sharing a code
66+
across sections. The migration checks for this first and aborts with a clear error rather
67+
than dropping the wide key and then failing to recreate the narrow one, which would leave
68+
the table with no unique key at all.
69+
70+
**NULL is not unique-constrained.** `custom_field_section_id` is nullable, and both MySQL
71+
and Postgres treat `NULL` as distinct from every other value in a unique index — including
72+
one that includes it. So after this migration, two sectionless fields
73+
(`custom_field_section_id IS NULL`) can still share a code for the same entity type, a
74+
collision the narrow key used to prevent. There is no schema-level fix for this; keep
75+
sectionless codes unique at the application layer if you rely on that guarantee.
76+
4877
## Breaking Changes
4978

5079
### High Impact

docs/content/2.essentials/6.data-model.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ The Custom Fields plugin employs a **Hybrid Entity-Attribute-Value (EAV) with Ty
2727
|--------|------|-------------|
2828
| `id` | bigint | Primary key |
2929
| `entity_type` | string | Polymorphic entity class |
30-
| `code` | string | Unique identifier |
30+
| `code` | string | Unique per entity type (+ tenant, when multi-tenancy is enabled) |
3131
| `name` | string | Display name |
3232
| `type` | string | Section type |
3333
| `width` | string | Section layout width (`CustomFieldWidth` enum: 25/33/50/66/75/100). Requires the `UI_SECTION_WIDTH_CONTROL` feature. |
@@ -44,7 +44,7 @@ The Custom Fields plugin employs a **Hybrid Entity-Attribute-Value (EAV) with Ty
4444
| `id` | bigint | Primary key |
4545
| `custom_field_section_id` | bigint | Parent section |
4646
| `entity_type` | string | Polymorphic entity class |
47-
| `code` | string | Unique identifier |
47+
| `code` | string | Unique per entity type and section (+ tenant, when multi-tenancy is enabled) — not globally unique, so the same code can exist in two different sections. See [Builder Scoping](/essentials/builder-scoping) |
4848
| `name` | string | Display name |
4949
| `type` | string | Field type (text, number, etc.) |
5050
| `lookup_type` | string | For lookup fields |
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
title: Builder Scoping
3+
description: Scope custom-field resolution to specific sections, and hook into code-uniqueness resolution
4+
navigation:
5+
icon: i-lucide-filter
6+
---
7+
8+
## onlySections()
9+
10+
By default, every builder (`CustomFields::form()`, `::infolist()`, `::table()`,
11+
`::exporter()`, `::importer()`) resolves fields for the whole entity type. `onlySections()`
12+
constrains that resolution to a specific set of `custom_field_sections` rows:
13+
14+
```php
15+
use Relaticle\CustomFields\Facades\CustomFields;
16+
17+
CustomFields::form()
18+
->forModel($model)
19+
->onlySections([$sectionA->id, $sectionB->id])
20+
->build();
21+
```
22+
23+
Passing `[]` (the default) means "no scope" — existing call sites that never call
24+
`onlySections()` are unaffected.
25+
26+
This exists for consumers that version their form definitions — for example, cloning a
27+
section (and its fields) per form version. Field codes are normally unique per entity
28+
type, so two versions of "the same field" would collide on `code` unless resolution is
29+
scoped by section. `onlySections()` lets each version's builder only see its own
30+
section(s), so the same code can live in more than one section without one bleeding into
31+
the other's schema.
32+
33+
`onlySections()` is inherited by every builder from `BaseBuilder`, including through
34+
`FormBuilder::build()` / `InfolistBuilder::build()` — the scope is threaded through
35+
`FormContainer` / `InfolistContainer` as well, so it applies whether you call `->build()`
36+
or `->values()`.
37+
38+
### The persistence contract — read this before relying on section-scoped codes
39+
40+
`onlySections()` narrows *resolution* (which fields get loaded onto a form, infolist, or
41+
table). It does not change how values are *saved*. `UsesCustomFields::saveCustomFields()`
42+
iterates the model's custom-field-values relationship and writes each submitted value by
43+
**field code**. If two sections share a code and that relationship isn't scoped to match
44+
`onlySections()`, `saveCustomFields()` will silently write the same value to **both**
45+
field rows — a data-corrupting outcome that has nothing to do with whether resolution
46+
scoping itself is working correctly.
47+
48+
If you use `onlySections()`, scope your model's custom-field-values relationship
49+
(`customFields()` by default, or your override) to the same section(s). It is
50+
entity-scoped by default and overridable per model.
51+
52+
## CodeGenerator::resolveUniquenessScopeUsing()
53+
54+
Auto-generated codes (`FIELD_CODE_AUTO_GENERATE`) are checked for collisions before use.
55+
Register a callback to narrow that check the same way `onlySections()` narrows
56+
resolution:
57+
58+
```php
59+
use Illuminate\Database\Eloquent\Builder;
60+
use Relaticle\CustomFields\Support\CodeGenerator;
61+
62+
CodeGenerator::resolveUniquenessScopeUsing(
63+
fn (string $entityType, string $type, int|string|null $sectionId): ?Closure => $sectionId !== null
64+
? fn (Builder $query): Builder => $query->where('custom_field_section_id', $sectionId)
65+
: null
66+
);
67+
```
68+
69+
The callback receives:
70+
71+
- `$entityType` — the entity the field or section belongs to.
72+
- `$type``'field'` or `'section'`, so you can scope differently per kind of code.
73+
- `$sectionId` — the section the code is being generated within, or `null` when there
74+
isn't one (for example, the sectionless field-management table).
75+
76+
Return `null` to leave the uniqueness check global — the default, backward-compatible
77+
behavior. Return a closure to narrow it: the closure receives the in-progress `Builder`
78+
and must **return** the query to apply. `where()`-style mutation also works (it returns
79+
the same instance), but a closure that hands back a different instance — e.g.
80+
`$query->clone()->where(...)` — is honored too, since the return value is always what's
81+
used.
82+
83+
Register the callback once, typically in a service provider's `boot()` method.

resources/lang/en/custom-fields.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
'default_section_name' => 'Default',
3030
'notifications' => [
3131
'created' => 'Section created',
32+
'duplicate_field_code' => 'This section already has a field with that code. Rename one of them before moving it here.',
3233
],
3334
'actions' => [
3435
'activate' => 'Activate',

src/CustomFieldsServiceProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ private function getMigrations(): array
206206
{
207207
return [
208208
'create_custom_fields_table',
209+
'relax_custom_fields_unique_key',
209210
];
210211
}
211212
}

0 commit comments

Comments
 (0)