Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f006881
feat: add onlySections() scope to custom field builders
ManukMinasyan Aug 7, 2026
35e746e
fix: relax custom_fields unique key to allow code reuse across sections
ManukMinasyan Aug 7, 2026
c962489
fix: guard onlySections() on sections-disabled installs, add migratio…
ManukMinasyan Aug 7, 2026
b293479
feat: thread section scope through form and infolist containers
ManukMinasyan Aug 7, 2026
17dbdc1
feat: add uniqueness scope hook to CodeGenerator
ManukMinasyan Aug 7, 2026
4ea007e
fix: honor table prefix and guard duplicate rows in relax-unique-key …
ManukMinasyan Aug 7, 2026
b33d535
fix: use scope closure return value in CodeGenerator, drop unused param
ManukMinasyan Aug 7, 2026
01a1ce4
fix: notify instead of 500ing when a section drag creates a duplicate…
ManukMinasyan Aug 7, 2026
4ebc708
test: cover InfolistContainer scope threading, fix two weak assertions
ManukMinasyan Aug 7, 2026
8aa2f4e
docs: correct data-model invariants, document builder scoping
ManukMinasyan Aug 7, 2026
5d8dcf3
fix: add code-uniqueness rule modifier hook to FieldForm
ManukMinasyan Aug 7, 2026
472cd55
fix: drop unreachable is_numeric arm in formatJsValue
ManukMinasyan Aug 8, 2026
86d03e4
style: apply rector fixes to section-scope code
ManukMinasyan Aug 8, 2026
91a3be4
test: pin numeric-string condition values to string comparison in vis…
ManukMinasyan Aug 8, 2026
fb7080f
fix: compare strings case-insensitively in generated visibility JS
ManukMinasyan Aug 8, 2026
5fa87da
fix: align server and client evaluation of the equals operator
ManukMinasyan Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions database/migrations/relax_custom_fields_unique_key.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Relaticle\CustomFields\Enums\CustomFieldsFeature;
use Relaticle\CustomFields\FeatureSystem\FeatureManager;

/*
* `onlySections()` (see BaseBuilder::onlySections()) lets consumers that version their
* form definitions scope custom-field resolution by section id instead of by code, so a
* cloned section can carry a field whose code already exists on its sibling section. The
* unique key created by create_custom_fields_table.php — (code, entity_type[, tenant]) —
* blocks exactly that data shape at the database level, so any consumer of onlySections()
* would fail to save the second field unless this key is widened to also include
* custom_field_section_id.
*
* custom_field_sections is deliberately left untouched: onlySections() scopes by section
* id, never by section code, so nothing here requires sections to share a code.
*
* custom_field_section_id is nullable, and both MySQL and Postgres treat NULL as distinct
* in a unique index — including within a composite one. So after this migration, two rows
* that both have custom_field_section_id IS NULL can still share (code, entity_type[,
* tenant]): the wide key does not constrain them, because NULL never equals NULL for
* uniqueness purposes. That's a protection existing installs have today (every row is
* globally unique per entity type) and silently lose once this ships. There is no schema
* workaround for this — a consumer that needs collision protection for sectionless fields
* must keep them out of this data shape or enforce it at the application layer.
*/
return new class extends Migration
{
public function up(): void
{
$this->swapUniqueKey(
from: $this->narrowColumns(),
fromIndexName: null,
to: $this->wideColumns(),
toIndexName: $this->wideIndexName(),
);
}

public function down(): void
{
$this->assertNoDuplicatesUnderNarrowKey();

$this->swapUniqueKey(
from: $this->wideColumns(),
fromIndexName: $this->wideIndexName(),
to: $this->narrowColumns(),
toIndexName: null,
);
}

/**
* MySQL runs each ALTER TABLE as its own auto-committing DDL statement, so dropping
* the wide key and adding the narrow one are not transactional together. If rows exist
* that share (code, entity_type[, tenant]) across different sections — exactly the
* shape the wide key exists to allow — the DROP succeeds and the subsequent ADD fails
* on the duplicate, leaving the table with neither unique key. Check first and abort
* before touching anything.
*/
private function assertNoDuplicatesUnderNarrowKey(): void
{
$table = config('custom-fields.database.table_names.custom_fields');

if (! Schema::hasColumn($table, 'custom_field_section_id')) {
return;
}

$columns = $this->narrowColumns();

$duplicateCodes = DB::table($table)
->select($columns)
->groupBy($columns)
->havingRaw('count(*) > 1')
->pluck('code');

if ($duplicateCodes->isEmpty()) {
return;
}

throw new RuntimeException(sprintf(
'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.',
$duplicateCodes->count(),
$duplicateCodes->first(),
implode(', ', $columns)
));
}

/**
* Drops $from's unique key if present and adds $to's if absent. Shared by both
* directions: up() widens (code, entity_type[, tenant]) to also include
* custom_field_section_id; down() narrows it back to the original key.
*
* @param array<int, string> $from
* @param array<int, string> $to
*/
private function swapUniqueKey(array $from, ?string $fromIndexName, array $to, ?string $toIndexName): void
{
$table = config('custom-fields.database.table_names.custom_fields');

if (! Schema::hasColumn($table, 'custom_field_section_id')) {
return;
}

$fromIndexName ??= $this->defaultUniqueIndexName($table, $from);
$toIndexName ??= $this->defaultUniqueIndexName($table, $to);
$existingIndexes = collect(Schema::getIndexes($table))->pluck('name');

Schema::table($table, function (Blueprint $blueprint) use ($existingIndexes, $fromIndexName, $to, $toIndexName): void {
if ($existingIndexes->contains($fromIndexName)) {
$blueprint->dropUnique($fromIndexName);
}

if (! $existingIndexes->contains($toIndexName)) {
$blueprint->unique($to, $toIndexName);
}
});
}

/**
* @return array<int, string>
*/
private function narrowColumns(): array
{
$columns = ['code', 'entity_type'];

if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) {
$columns[] = config('custom-fields.database.column_names.tenant_foreign_key');
}

return $columns;
}

/**
* @return array<int, string>
*/
private function wideColumns(): array
{
return [...$this->narrowColumns(), 'custom_field_section_id'];
}

private function wideIndexName(): string
{
return FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)
? 'cf_code_entity_tenant_section_unique'
: 'cf_code_entity_section_unique';
}

/**
* Mirrors Laravel's own auto-generated unique-index name (Blueprint::createIndexName())
* so the drop target matches exactly what create_custom_fields_table.php produced,
* without hardcoding a name that would drift if a configured column name changes.
*
* Laravel's shipped config/database.php enables `prefix_indexes` for mysql and pgsql,
* which makes Blueprint::createIndexName() fold the connection's table prefix into the
* name it generates. Skipping that step here would compute a drop target that never
* matches the real index name on a prefixed install, so the drop would silently no-op.
*
* @param array<int, string> $columns
*/
private function defaultUniqueIndexName(string $table, array $columns): string
{
$connection = Schema::getConnection();

$prefixedTable = $connection->getConfig('prefix_indexes')
? $this->applyTablePrefix($table, $connection->getTablePrefix())
: $table;

$index = strtolower($prefixedTable.'_'.implode('_', $columns).'_unique');

return str_replace(['-', '.'], '_', $index);
}

private function applyTablePrefix(string $table, string $prefix): string
{
return str_contains($table, '.')
? substr_replace($table, '.'.$prefix, strrpos($table, '.'), 1)
: $prefix.$table;
}
};
15 changes: 15 additions & 0 deletions docs/content/1.getting-started/1.installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,18 @@ php artisan vendor:publish --tag="custom-fields-translations"
```bash
php artisan vendor:publish --tag="custom-fields-views"
```

## Picking Up New Migrations After an Upgrade

Package migrations are not run automatically by `php artisan migrate` after a version
bump — they're only copied into your app the first time you install, or when you
explicitly republish them. If a release adds or changes a migration, republish and run
it:

```bash
php artisan vendor:publish --tag="custom-fields-migrations"
php artisan migrate
```

See the [upgrade guide](/getting-started/upgrade-guide#picking-up-new-migrations) for a
concrete example of a release that requires this step.
29 changes: 29 additions & 0 deletions docs/content/1.getting-started/3.upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,35 @@ php artisan custom-fields:upgrade --skip=clear-caches
php artisan custom-fields:upgrade --skip=email-format,phone-format
```

## Picking Up New Migrations

Package migrations are not run automatically by `php artisan migrate` after a version
bump — they're only copied into your app on first install, or when you explicitly
republish them:

```bash
php artisan vendor:publish --tag="custom-fields-migrations"
php artisan migrate
```

For example, a release may relax the `custom_fields` unique key from
`(code, entity_type[, tenant])` to `(code, entity_type[, tenant], custom_field_section_id)`
so a field code can be reused across different sections — the shape `onlySections()` (see
[Builder Scoping](/essentials/builder-scoping)) relies on. Existing installs need the
republish-and-migrate step above to pick that change up; it is not applied automatically.

**Before rolling that migration back**, resolve any rows that ended up sharing a code
across sections. The migration checks for this first and aborts with a clear error rather
than dropping the wide key and then failing to recreate the narrow one, which would leave
the table with no unique key at all.

**NULL is not unique-constrained.** `custom_field_section_id` is nullable, and both MySQL
and Postgres treat `NULL` as distinct from every other value in a unique index — including
one that includes it. So after this migration, two sectionless fields
(`custom_field_section_id IS NULL`) can still share a code for the same entity type, a
collision the narrow key used to prevent. There is no schema-level fix for this; keep
sectionless codes unique at the application layer if you rely on that guarantee.

## Breaking Changes

### High Impact
Expand Down
4 changes: 2 additions & 2 deletions docs/content/2.essentials/6.data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The Custom Fields plugin employs a **Hybrid Entity-Attribute-Value (EAV) with Ty
|--------|------|-------------|
| `id` | bigint | Primary key |
| `entity_type` | string | Polymorphic entity class |
| `code` | string | Unique identifier |
| `code` | string | Unique per entity type (+ tenant, when multi-tenancy is enabled) |
| `name` | string | Display name |
| `type` | string | Section type |
| `width` | string | Section layout width (`CustomFieldWidth` enum: 25/33/50/66/75/100). Requires the `UI_SECTION_WIDTH_CONTROL` feature. |
Expand All @@ -44,7 +44,7 @@ The Custom Fields plugin employs a **Hybrid Entity-Attribute-Value (EAV) with Ty
| `id` | bigint | Primary key |
| `custom_field_section_id` | bigint | Parent section |
| `entity_type` | string | Polymorphic entity class |
| `code` | string | Unique identifier |
| `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) |
| `name` | string | Display name |
| `type` | string | Field type (text, number, etc.) |
| `lookup_type` | string | For lookup fields |
Expand Down
83 changes: 83 additions & 0 deletions docs/content/2.essentials/7.builder-scoping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
title: Builder Scoping
description: Scope custom-field resolution to specific sections, and hook into code-uniqueness resolution
navigation:
icon: i-lucide-filter
---

## onlySections()

By default, every builder (`CustomFields::form()`, `::infolist()`, `::table()`,
`::exporter()`, `::importer()`) resolves fields for the whole entity type. `onlySections()`
constrains that resolution to a specific set of `custom_field_sections` rows:

```php
use Relaticle\CustomFields\Facades\CustomFields;

CustomFields::form()
->forModel($model)
->onlySections([$sectionA->id, $sectionB->id])
->build();
```

Passing `[]` (the default) means "no scope" — existing call sites that never call
`onlySections()` are unaffected.

This exists for consumers that version their form definitions — for example, cloning a
section (and its fields) per form version. Field codes are normally unique per entity
type, so two versions of "the same field" would collide on `code` unless resolution is
scoped by section. `onlySections()` lets each version's builder only see its own
section(s), so the same code can live in more than one section without one bleeding into
the other's schema.

`onlySections()` is inherited by every builder from `BaseBuilder`, including through
`FormBuilder::build()` / `InfolistBuilder::build()` — the scope is threaded through
`FormContainer` / `InfolistContainer` as well, so it applies whether you call `->build()`
or `->values()`.

### The persistence contract — read this before relying on section-scoped codes

`onlySections()` narrows *resolution* (which fields get loaded onto a form, infolist, or
table). It does not change how values are *saved*. `UsesCustomFields::saveCustomFields()`
iterates the model's custom-field-values relationship and writes each submitted value by
**field code**. If two sections share a code and that relationship isn't scoped to match
`onlySections()`, `saveCustomFields()` will silently write the same value to **both**
field rows — a data-corrupting outcome that has nothing to do with whether resolution
scoping itself is working correctly.

If you use `onlySections()`, scope your model's custom-field-values relationship
(`customFields()` by default, or your override) to the same section(s). It is
entity-scoped by default and overridable per model.

## CodeGenerator::resolveUniquenessScopeUsing()

Auto-generated codes (`FIELD_CODE_AUTO_GENERATE`) are checked for collisions before use.
Register a callback to narrow that check the same way `onlySections()` narrows
resolution:

```php
use Illuminate\Database\Eloquent\Builder;
use Relaticle\CustomFields\Support\CodeGenerator;

CodeGenerator::resolveUniquenessScopeUsing(
fn (string $entityType, string $type, int|string|null $sectionId): ?Closure => $sectionId !== null
? fn (Builder $query): Builder => $query->where('custom_field_section_id', $sectionId)
: null
);
```

The callback receives:

- `$entityType` — the entity the field or section belongs to.
- `$type` — `'field'` or `'section'`, so you can scope differently per kind of code.
- `$sectionId` — the section the code is being generated within, or `null` when there
isn't one (for example, the sectionless field-management table).

Return `null` to leave the uniqueness check global — the default, backward-compatible
behavior. Return a closure to narrow it: the closure receives the in-progress `Builder`
and must **return** the query to apply. `where()`-style mutation also works (it returns
the same instance), but a closure that hands back a different instance — e.g.
`$query->clone()->where(...)` — is honored too, since the return value is always what's
used.

Register the callback once, typically in a service provider's `boot()` method.
1 change: 1 addition & 0 deletions resources/lang/en/custom-fields.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
'default_section_name' => 'Default',
'notifications' => [
'created' => 'Section created',
'duplicate_field_code' => 'This section already has a field with that code. Rename one of them before moving it here.',
],
'actions' => [
'activate' => 'Activate',
Expand Down
1 change: 1 addition & 0 deletions src/CustomFieldsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ private function getMigrations(): array
{
return [
'create_custom_fields_table',
'relax_custom_fields_unique_key',
];
}
}
Loading