diff --git a/.gitattributes b/.gitattributes index 9a1dba6..b6da5eb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,10 +4,14 @@ /.gitattributes export-ignore /.gitignore export-ignore /.editorconfig export-ignore +/.phpunit.cache export-ignore +/art export-ignore +/build export-ignore /tests export-ignore /phpunit.xml.dist export-ignore /phpstan.neon.dist export-ignore /pint.json export-ignore +/rector.php export-ignore /testbench.yaml export-ignore /workbench export-ignore /.claude export-ignore @@ -15,3 +19,5 @@ /CLAUDE.md export-ignore /AGENTS.md export-ignore /AGENTS_PACKAGE.md export-ignore +/PLAN.md export-ignore +/IMPLEMENTATION.md export-ignore diff --git a/.gitignore b/.gitignore index d0ee595..b3881b0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ composer.lock .DS_Store .idea .vscode +*.local diff --git a/CHANGELOG.md b/CHANGELOG.md index dbbae55..d1147a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,116 @@ All notable changes to this project are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + ## 0.1.0 - 2026-09-07 -The package supports typed custom field definitions and values, stable slugs, -structured options, partial and complete validation, native Eloquent filters and -sorts, configurable internal keys, morph aliases, post commit events, factories, -and custom field type extensions. +First public release. + +### Added + +- Typed custom field definitions bound to a registered entity, with a stable slug + generated once from the name and unique per entity. +- Twelve built in field types: `text`, `textarea`, `email`, `url`, `phone`, `number`, + `decimal`, `boolean`, `date`, `datetime`, `select`, `multiselect`. Each type owns its + storage column, validation rules, serialization, default, labels and the query + operations it declares. +- The `FieldType` contract and `CustomFields::registerType()`, so a product can add its + own types. +- The `HasCustomFields` trait with `getCustomField()`, `getCustomFields()`, + `setCustomField()`, `setCustomFields()`, `clearCustomField()` and the + `whereCustomField()` query scope. +- Partial and complete validation through `ValueValidator`, reachable as + `CustomFields::validate()` and `CustomFields::validator()->rules()`. +- Structured options with stable keys, plus `updateOptions()`, `optionsForInput()`, + `optionKeys()` and `activeOptionKeys()`. An inactive option stays readable on the + records that already hold it and is never offered to a new one. +- Request filters and sorts for `spatie/laravel-query-builder` through + `CustomFields::filtersFor()`, `sortsFor()` and `queryOptionsFor()`, with names built + from the configurable `key_prefix` by `filterName()` and `sortName()`. +- Ten filter operations: `equals`, `in`, `contains`, `greater_than`, `less_than`, + `between`, `is_null`, `is_not_null`, `contains_any`, `contains_all`. Each one is + registered only for the types that declare it, so nothing arbitrary can be built from + a request. +- Sorting on a custom field through a correlated subquery, which leaves the caller's + select, aggregates and existing order untouched and places the records without a value + last in both directions. +- Five events dispatched after the surrounding transaction commits: `CustomFieldCreated`, + `CustomFieldUpdated`, `CustomFieldDeleted`, `CustomFieldValueSaved`, + `CustomFieldValueDeleted`. +- `UnknownCustomFieldException` and `ModelNotPersistedException`. +- Configurable tables, key types (`id`, `uuid`, `ulid`), models and filter prefix, + validated while the service provider registers so a typo fails at boot. +- English and Italian validation messages under the `laravel-custom-fields` translation + namespace. +- Model factories for both package models. +- A README covering installation, configuration, the field type table, the query + operations, the events, the exceptions, the form metadata and the test commands. + +### Changed + +- `morph_key_type` now defaults to `id` instead of `uuid`. A fresh installation matches a + default Laravel application and gets a `bigint` `valuable_id` column. `uuid` and `ulid` + stay fully supported and still have to be chosen before the migrations run. +- The service provider is built on `spatie/laravel-package-tools`. The publish tags are + unchanged (`laravel-custom-fields`, `laravel-custom-fields-config`, + `laravel-custom-fields-lang`, `laravel-custom-fields-migrations`), and a published + migration now receives a fresh timestamp instead of keeping the packaged one. +- The migrations are named `create_custom_fields_table` and + `create_custom_field_values_table`. +- `getCustomField()` takes a second argument, `includeInactive`, mirroring + `getCustomFields()`. Reading a deactivated definition without it throws. +- `clearCustomField()` works on a deactivated definition with no flag, otherwise retired + data could never be removed. +- An unknown or inactive slug submitted to `setCustomField()` or `setCustomFields()` is + reported as a `ValidationException` keyed by slug, before the transaction opens, rather + than as an `InvalidArgumentException` in the middle of a write. The direct accessors + keep throwing `UnknownCustomFieldException`, which extends `InvalidArgumentException`. +- Validation messages resolve through the translation namespace and use the field name as + the attribute, so an error reads "The date of birth field is required." instead of + naming the raw slug. +- `filtersFor()` returns one filter per declared operation instead of a single equality + filter per field, and the filter and sort names come from `key_prefix` instead of a + hardcoded `cf_`. +- `BooleanType::serialize()` reads textual booleans through `filter_var`, so + `filter[cf_flag]=true` matches instead of returning nothing. +- `CustomFieldSorter` refuses a field type that does not declare `sort` at construction, + and `CustomFieldFilter` refuses an operation the type does not declare. + +### Fixed + +- Complete validation no longer fails on a required field that is already stored and + absent from the payload. It still fails when that field is explicitly submitted as + `null`, and when it was never stored at all. +- Passing `null` now deletes the stored row inside the same transaction as the rest of the + batch and dispatches `CustomFieldValueDeleted`, instead of writing a row with every + value column set to null. An empty array on a `multiselect` keeps the row, so a cleared + selection stays distinct from a field that was never answered. +- A `multiselect` no longer receives an equality filter its JSON storage cannot serve, + which silently returned no rows. It exposes `contains_any` and `contains_all`, built on + `whereJsonContains`. +- The `contains` filter escapes `%`, `_`, `!` and the backslash with an explicit escape + clause, so those characters are matched literally on MySQL, PostgreSQL and SQLite. +- `setCustomFields()` rejects a model that has not been saved with + `ModelNotPersistedException` before touching the database, instead of failing on a NOT + NULL constraint. +- Reads and writes no longer issue one query per field. The definitions and the stored + rows are loaded once per call and shared between validation and writing. +- `setCustomFields()` releases the `customFieldValues` relation after a write, so an eager + loaded relation is not left stale. +- A generated slug stays within 100 characters once a collision suffix is appended, and + no longer ends up with a doubled dash. +- The sort no longer depends on where each database places a null. +- Complete validation decides whether a required field is satisfied without replaying the + input rules of its type over the value already stored. A type whose read shape differs + from its write shape, which the custom type extension point explicitly allows, no longer + makes every complete write fail on a field the caller never touched. +- The values migration falls back to `id` rather than `uuid` when `morph_key_type` is + absent, so a missing config key no longer builds a values table that matches neither + documented default. +- The test suite pins an in memory database, so `composer build` followed by + `composer test` no longer fails on the migrations that `workbench:build` publishes into + the Testbench skeleton. diff --git a/README.md b/README.md index ee5a633..2df0938 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,28 @@ Typed, extensible custom fields for Eloquent models with filtering and sorting. +Your product defines the fields at runtime, your users fill them in, and the package +stores every value in a typed column, validates it, and exposes it to a request as a +filter and as a sort. The package is headless. It ships no controllers, no routes, no +authorization and no UI. + +## Contents + +- [Installation](#installation) +- [Configuration](#configuration) +- [Registering an entity](#registering-an-entity) +- [Defining fields](#defining-fields) +- [Field types](#field-types) +- [Reading and writing values](#reading-and-writing-values) +- [Validation](#validation) +- [Exceptions](#exceptions) +- [Options](#options) +- [Querying](#querying) +- [Building a form](#building-a-form) +- [Events](#events) +- [Custom field types](#custom-field-types) +- [Testing](#testing) + ## Installation You can install the package via Composer: @@ -24,8 +46,20 @@ You can install the package via Composer: composer require plin-code/laravel-custom-fields ``` -The package requires PHP 8.4 or newer and supports Laravel 12 and 13. It also uses -Spatie Query Builder and the Plin Code Eloquent sorts adapter for list integrations. +The package requires PHP 8.4 or newer and supports Laravel 12 and 13. + +Three packages are installed with it: + +- `spatie/laravel-package-tools` wires the service provider, the config file, the + translations and the migrations. +- `spatie/laravel-query-builder` provides the `AllowedFilter` and `AllowedSort` classes. + `CustomFields::filtersFor()` and `CustomFields::sortsFor()` return instances of them, + so you need it only when you expose custom fields to an HTTP request. +- `plin-code/laravel-eloquent-sorts` is used for one thing, `Direction::normalise()`, + which validates the direction inside the custom field sort. Having it installed also + gives you the relation, relation count and enum sorts of that package, which compose + with the custom field sorts in the same `allowedSorts()` call. See + [composing with other sorts](#composing-with-other-sorts). You may publish all of the package's resources at once: @@ -48,10 +82,14 @@ php artisan vendor:publish --tag="laravel-custom-fields-migrations" php artisan migrate ``` -Set key_type and morph_key_type in the published configuration before running the -migrations. Each accepts id, uuid, or ulid. The default internal key is id, while -the default key for owning models is uuid. A single installation must use one key -type consistently. +Two migrations are published, `create_custom_fields_table` and +`create_custom_field_values_table`. They are published one file at a time and receive a +fresh timestamp, so they run after the migrations already in your application. + +> [!IMPORTANT] +> Set `key_type` and `morph_key_type` in the published configuration **before** you run +> the migrations. Each accepts `id`, `uuid` or `ulid`, and each defaults to `id`. +> Changing either one once the tables exist requires a data migration you write yourself. ### Publishing the Translations @@ -59,19 +97,77 @@ type consistently. php artisan vendor:publish --tag="laravel-custom-fields-lang" ``` -## Usage +The package ships English and Italian validation messages under the +`laravel-custom-fields` translation namespace. Published files land in +`lang/vendor/laravel-custom-fields`. + +## Configuration + +| Key | Default | What it does | +| --- | --- | --- | +| `tables.fields` | `custom_fields` | Table holding the field definitions. | +| `tables.values` | `custom_field_values` | Table holding one row per field per record. | +| `key_type` | `id` | Primary key type of both package tables. Accepts `id`, `uuid`, `ulid`. Must be chosen before migrating. | +| `morph_key_type` | `id` | Key type of the models that own custom fields, used for the `valuable_id` column. Accepts `id`, `uuid`, `ulid`. Must be chosen before migrating. | +| `models.custom_field` | `PlinCode\CustomFields\Models\CustomField` | The definition model. Point it at your own subclass to add relations, casts, a global scope or a dedicated connection. | +| `models.custom_field_value` | `PlinCode\CustomFields\Models\CustomFieldValue` | The value model. Same rule. | +| `key_prefix` | `cf_` | Prefix of every filter and sort name the package exposes to a request. | -Register the models that can own custom fields during application boot: +An unsupported value in `key_type` or `morph_key_type` throws an +`InvalidArgumentException` while the service provider registers, so a typo fails at boot +and not at the first query. + +If you replace either model, resolve it through the package rather than referencing the +class directly, so your code keeps following the configuration: ```php use PlinCode\CustomFields\Facades\CustomFields; -CustomFields::registerEntity(\App\Models\Patient::class, 'patient', 'Patient'); +$fieldModel = CustomFields::fieldModel(); +$valueModel = CustomFields::valueModel(); ``` -Add the trait to the owning model: +## Registering an entity + +A model can own custom fields once it is registered under a short entity key. The key is +stored in the `entity_type` column and is added to the Eloquent morph map, so registration +has to happen on every request. The right place is the `boot()` method of a service +provider: ```php + [!WARNING] +> `CustomFields::filtersFor()`, `CustomFields::sortsFor()` and +> `CustomFields::queryOptionsFor()` read the definitions from the database. Call them +> while serving a request, never from `register()` or `boot()`, where the connection and +> the tenant context are not settled yet. -~~~php -use App\Models\User; +## Defining fields + +Definitions are rows your product creates. A name is trimmed and stored in lowercase, and +the slug is generated once from the name and never changes afterwards. The slug is the key +your application uses everywhere. + +Nothing checks `entity_type` against the registry, so resolve it with `entityKey()` rather +than typing the string. A typo creates a definition that every read, write, filter and sort +ignores, with no error anywhere. + +```php +use App\Models\Patient; use PlinCode\CustomFields\Facades\CustomFields; use PlinCode\CustomFields\Models\CustomField; -CustomFields::registerEntity(User::class, 'user', 'User'); +$patientKey = CustomFields::entityKey(Patient::class); // 'patient' -CustomField::create([ - 'entity_type' => 'user', - 'name' => 'Sesso', +$riskLevel = CustomField::create([ + 'entity_type' => $patientKey, + 'name' => ' Risk level ', 'type' => 'select', + 'is_required' => true, + 'sort_order' => 10, 'options' => [ - ['key' => 'm', 'label' => 'M', 'is_active' => true], - ['key' => 'f', 'label' => 'F', 'is_active' => true], + ['key' => 'low', 'label' => 'Low', 'is_active' => true], + ['key' => 'high', 'label' => 'High', 'is_active' => true], + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => false], ], ]); +$riskLevel->name; // 'risk level' +$riskLevel->slug; // 'risk-level' + CustomField::create([ - 'entity_type' => 'user', - 'name' => 'Data di nascita', + 'entity_type' => $patientKey, + 'name' => 'Date of birth', 'type' => 'date', ]); CustomField::create([ - 'entity_type' => 'user', - 'name' => 'Consenso marketing', - 'type' => 'boolean', + 'entity_type' => $patientKey, + 'name' => 'Allergies', + 'type' => 'multiselect', + 'options' => [ + ['key' => 'pollen', 'label' => 'Pollen', 'is_active' => true], + ['key' => 'latex', 'label' => 'Latex', 'is_active' => true], + ], ]); -~~~ -After creating a user, assign and read the values through the owning model: +CustomField::create([ + 'entity_type' => $patientKey, + 'name' => 'Notes', + 'type' => 'textarea', +]); +``` + +The columns of a definition are: + +| Column | Meaning | +| --- | --- | +| `entity_type` | The entity key registered with `registerEntity()`. | +| `name` | The human name. Trimmed and lowercased on write, unique per entity. | +| `slug` | Generated from the name on creation, unique per entity, at most 100 characters. It cannot be changed afterwards. | +| `type` | One of the keys in the [field types](#field-types) table. It cannot be changed once values exist. | +| `options` | The option list of a `select` or a `multiselect`. See [options](#options). | +| `is_required` | Whether [complete validation](#validation) demands a value. | +| `is_active` | Whether the field takes part in ordinary reads, writes, filters and sorts. | +| `sort_order` | A hint your product can order its form by. The package stores it and does not order anything by it. | + +## Field types + +Twelve types are registered out of the box. The `Key` column is the exact string you put +in the `type` column of a definition. + +| Key | Label | Storage column | Input hint | Query operations | +| --- | --- | --- | --- | --- | +| `text` | Text | `value_string` | `text` | `equals`, `in`, `contains`, `is_null`, `is_not_null`, `sort` | +| `textarea` | Textarea | `value_text` | `textarea` | `equals`, `contains`, `is_null`, `is_not_null` | +| `email` | Email | `value_string` | `text` | `equals`, `in`, `contains`, `is_null`, `is_not_null`, `sort` | +| `url` | URL | `value_string` | `text` | `equals`, `in`, `contains`, `is_null`, `is_not_null`, `sort` | +| `phone` | Phone | `value_string` | `text` | `equals`, `in`, `contains`, `is_null`, `is_not_null`, `sort` | +| `number` | Number | `value_integer` | `number` | `equals`, `in`, `greater_than`, `less_than`, `between`, `is_null`, `is_not_null`, `sort` | +| `decimal` | Decimal | `value_decimal` | `number` | `equals`, `in`, `greater_than`, `less_than`, `between`, `is_null`, `is_not_null`, `sort` | +| `boolean` | Boolean | `value_boolean` | `checkbox` | `equals`, `is_null`, `is_not_null`, `sort` | +| `date` | Date | `value_date` | `date` | `equals`, `greater_than`, `less_than`, `between`, `is_null`, `is_not_null`, `sort` | +| `datetime` | Date and time | `value_datetime` | `datetime-local` | `equals`, `greater_than`, `less_than`, `between`, `is_null`, `is_not_null`, `sort` | +| `select` | Select | `value_string` | `select` | `equals`, `in`, `is_null`, `is_not_null`, `sort` | +| `multiselect` | Multiple select | `value_json` | `multiselect` | `contains_any`, `contains_all`, `is_null`, `is_not_null` | + +Note that the multiple select key is `multiselect` and not `multi_select`. + +The validation rules each type applies to a submitted value: + +| Key | Rules | +| --- | --- | +| `text`, `phone` | `nullable`, `string`, `max:255` | +| `textarea` | `nullable`, `string` | +| `email` | `nullable`, `email`, `max:255` | +| `url` | `nullable`, `url`, `max:255` | +| `number` | `nullable`, `integer` | +| `decimal` | `nullable`, `numeric` | +| `boolean` | `nullable`, `boolean` | +| `date`, `datetime` | `nullable`, `date` | +| `select` | `nullable`, `string`, and the value has to be an assignable option key | +| `multiselect` | `nullable`, `array`, and every element has to be an assignable option key | + +The default a read returns when nothing is stored is `null` for every type except +`multiselect`, whose default is an empty array. + +## Reading and writing values -~~~php -$user->setCustomFields([ - 'sesso' => 'f', - 'data-di-nascita' => '1990-05-12', - 'consenso-marketing' => true, +```php +$patient->setCustomFields([ + 'risk-level' => 'high', + 'date-of-birth' => '1985-03-02', + 'allergies' => ['pollen', 'latex'], ]); -$user->getCustomFields(); +$patient->getCustomFields(); // [ -// 'sesso' => 'f', -// 'data-di-nascita' => '1990-05-12', -// 'consenso-marketing' => true, +// 'risk-level' => 'high', +// 'date-of-birth' => '1985-03-02', +// 'allergies' => ['pollen', 'latex'], +// 'notes' => null, // ] -~~~ -The date field and boolean field are optional by default. Set is_required on a -definition and request complete validation when the product requires the full -profile. +$patient->getCustomField('risk-level'); // 'high' +$patient->setCustomField('notes', 'Follow up in June.'); +$patient->clearCustomField('notes'); +``` + +`setCustomFields()` validates the whole batch first and then writes it inside a single +transaction, so a rejected batch leaves nothing behind. `setCustomField()` is a single key +call onto the same path. Both refuse a model that has not been saved yet and throw a +`ModelNotPersistedException` without issuing a query. -Definitions are created by the product and belong to one registered entity. Names are trimmed and stored in lowercase. The generated slug is stable and is the key used by the application: +`getCustomFields()` returns every active field of the entity keyed by slug, including the +fields with no stored value, so the array shape is stable and safe to hand to a form. + +### Clearing a value + +Passing `null` deletes the stored row. Reading the field afterwards returns the default of +its type, and a `CustomFieldValueDeleted` event is dispatched. ```php -$field = CustomField::create([ - 'entity_type' => 'patient', - 'name' => ' Risk level ', - 'type' => 'select', - 'options' => [ - ['key' => 'low', 'label' => 'Low', 'is_active' => true], - ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => false], - ], +$patient->setCustomField('date-of-birth', null); // the row is deleted +$patient->getCustomField('date-of-birth'); // null + +$patient->clearCustomField('date-of-birth'); // the same thing, explicitly +``` + +An empty array is not the same as `null` on a `multiselect`. An empty array is an answered +field, so the row is kept and the value reads back as `[]`. Only `null` deletes the row. + +```php +$patient->setCustomField('allergies', []); // row kept, reads back as [] +$patient->setCustomField('allergies', null); // row deleted, reads back as [] +``` + +### Deactivated definitions + +Setting `is_active` to `false` on a definition retires it without destroying the data +already stored. The behaviour is not uniform across the API, because a read and a write +have different needs: + +| Call | On an inactive definition | +| --- | --- | +| `getCustomFields()` | The field is left out. | +| `getCustomFields(includeInactive: true)` | The field is included. | +| `getCustomField($slug)` | Throws `UnknownCustomFieldException`. | +| `getCustomField($slug, includeInactive: true)` | Returns the stored value. | +| `setCustomField()`, `setCustomFields()` | Throws `ValidationException`. A retired field can never be written again. | +| `clearCustomField($slug)` | Removes the value. No flag is needed, otherwise retired data could never be deleted. | +| `filtersFor()`, `sortsFor()`, `queryOptionsFor()` | The field is left out, so a request cannot filter or sort on it. | +| `whereCustomField()` | Throws `UnknownCustomFieldException`. The scope takes no opt in, so a retired field cannot be queried through it. Read the stored values with `getCustomFields(includeInactive: true)` instead. | + +## Validation + +The package validates in two modes. + +**Partial** is the default. Only the submitted keys are inspected, so a required field that +is absent from the payload is not checked. This is what a PATCH endpoint wants. + +**Complete** inspects the state the record ends up with, the values already stored merged +with the changes you submit. A required field that is already stored may be left out of the +payload, while a required field that is explicitly submitted as `null`, or that was never +stored at all, fails. + +```php +$patient->setCustomFields(['notes' => 'Follow up.']); // partial +$patient->setCustomFields(['notes' => 'Follow up.'], complete: true); // complete +``` + +You can validate without writing, which is useful in a form request: + +```php +use PlinCode\CustomFields\Facades\CustomFields; + +CustomFields::validate($patient, $request->input('custom_fields', []), complete: true); +``` + +Or take the rule array and merge it into your own rules: + +```php +$rules = CustomFields::validator()->rules(Patient::class, complete: true); +// ['risk-level' => ['required', 'string', ...], 'date-of-birth' => ['nullable', 'date'], ...] +``` + +> [!WARNING] +> The rule array is not equivalent to `CustomFields::validate()`. It carries the rules of +> each type and nothing else, so it does not reject a slug that is unknown or inactive, and +> it accepts an option key that exists but is no longer assignable. `validate()` performs +> both of those checks on top of the rules. Use the array to drive a form request, then let +> the write path validate again, which it always does. + +Messages come from the `laravel-custom-fields` translation namespace, except the required +message, which is Laravel's own `validation.required` so it follows your application +locale. Both use the field name rather than the slug as the attribute, so a field named +`date of birth` produces "The date of birth field is required." and not +"The date-of-birth field is required." + +A required field needs a value that is actually filled. `null`, an empty string and an +empty array all fail complete validation, which for a required `multiselect` means at least +one option has to be selected. That is a separate question from storage, where an empty +array is still an answered field and keeps its row. + +## Exceptions + +| Exception | Thrown by | When | Reasonable response | +| --- | --- | --- | --- | +| `Illuminate\Validation\ValidationException` | `setCustomField()`, `setCustomFields()`, `CustomFields::validate()` | A value fails the rules of its type, a required field is missing under complete validation, a submitted slug is unknown, a submitted definition is inactive, or an option key cannot be assigned. Errors are keyed by slug. | 422 | +| `PlinCode\CustomFields\Exceptions\ModelNotPersistedException` | `setCustomField()`, `setCustomFields()` | The host model has no key yet. | 500, it is a programming error | +| `PlinCode\CustomFields\Exceptions\UnknownCustomFieldException` | `getCustomField()`, `clearCustomField()`, `whereCustomField()` | The slug is not defined for the entity. Also when it is inactive, except for `clearCustomField()`, which always works, and for `getCustomField($slug, includeInactive: true)`. `whereCustomField()` has no opt in. | 404 or 500, depending on whether the slug came from a request | +| `InvalidArgumentException` | `CustomFields::type()`, `registerType()`, `registerEntity()`, `entityKey()`, `updateOptions()`, and the definition observer | An unknown type key, a type key registered twice, a duplicated entity key, an unregistered model, an option removal, an empty name, a duplicated option key, a slug or entity change, or a type change while values exist. | 500 | +| `Illuminate\Database\UniqueConstraintViolationException` | `CustomField::create()` | Two definitions of the same entity are given the same name. The uniqueness is enforced by the database, not by the observer, so it surfaces as a query exception. | 409 or 422, after you catch it | + +`UnknownCustomFieldException` extends `InvalidArgumentException` and +`ModelNotPersistedException` extends `RuntimeException`, so an existing catch on the parent +class keeps matching. + +Because a slug that arrives from a request is a client mistake and not a server one, the +write path reports an unknown or inactive slug as a validation error rather than as an +exception you have to translate yourself: + +```php +use Illuminate\Validation\ValidationException; + +try { + $patient->setCustomFields($request->input('custom_fields', [])); +} catch (ValidationException $e) { + $e->errors(); // ['risk-level' => ['The selected option legacy is invalid for risk level.']] +} +``` + +## Options + +A `select` and a `multiselect` carry an option list. Every option has a stable `key`, a +`label` you can rename freely, and an `is_active` flag. + +```php +$riskLevel->updateOptions([ + ['key' => 'low', 'label' => 'Low risk', 'is_active' => true], + ['key' => 'high', 'label' => 'High risk', 'is_active' => true], + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => false], + ['key' => 'critical', 'label' => 'Critical', 'is_active' => true], ]); +``` -$patient->setCustomField($field->slug, 'low'); -$patient->getCustomField($field->slug); +New keys can be appended and labels can change. An existing key can be deactivated but +never removed or reused, and trying to remove one throws an `InvalidArgumentException`, +because a stored value would lose its meaning. + +An inactive option stays readable. A record that already holds it keeps it and can save +it again, while any other record is rejected with the `invalid_option` message. Inactive +options are excluded from `optionsForInput()`, so your form never offers them. + +```php +$riskLevel->optionsForInput(); // the active options, ready for a form +$riskLevel->activeOptionKeys(); // ['low', 'high', 'critical'] +$riskLevel->optionKeys(); // ['low', 'high', 'legacy', 'critical'] ``` -Partial updates validate only submitted values. A product can request complete -validation when it needs every required active field: +## Querying - $patient->setCustomFields(['risk-level' => 'low'], complete: true); +### A worked example -Passing null clears a stored value. clearCustomField() removes it explicitly. -Inactive definitions are excluded from ordinary reads, form metadata, filters, and -sorts. Use getCustomFields(includeInactive: true) for an explicit historical read. +Three patients, the definitions from [defining fields](#defining-fields), and the values +their records hold: -Options use stable keys. Labels can change, while inactive options remain readable on existing records and are excluded from the input metadata returned by `optionsForInput()`. -Use updateOptions() to change labels, activate or deactivate options, and append new -keys. Existing keys cannot be removed or reused. +| Patient | `risk-level` | `date-of-birth` | `allergies` | +| --- | --- | --- | --- | +| Anna Rossi | `high` | 1981-04-02 | `['pollen']` | +| Bruno Neri | `high` | 1974-11-20 | `['pollen', 'latex']` | +| Carla Verdi | `low` | 1990-06-30 | no value | -For API lists, expose only the fields the product wants to make available: +One controller serves all of it: ```php -QueryBuilder::for(Patient::class) +use App\Models\Patient; +use PlinCode\CustomFields\Facades\CustomFields; +use Spatie\QueryBuilder\QueryBuilder; + +public function index() +{ + $options = CustomFields::queryOptionsFor(Patient::class); + + return QueryBuilder::for(Patient::class) + ->allowedFilters(...$options['filters']) + ->allowedSorts(...$options['sorts']) + ->paginate(); +} +``` + +What the client sends, and what comes back: + +| Request | Result | +| --- | --- | +| `?filter[cf_risk-level]=high` | Anna Rossi, Bruno Neri | +| `?filter[cf_allergies:contains_any]=latex` | Bruno Neri | +| `?filter[cf_allergies:contains_all]=pollen,latex` | Bruno Neri | +| `?filter[cf_date-of-birth:between]=1980-01-01,1995-12-31` | Anna Rossi, Carla Verdi | +| `?filter[cf_allergies:is_null]=1` | Carla Verdi | +| `?filter[cf_risk-level]=high&sort=cf_date-of-birth` | Bruno Neri, Anna Rossi | +| `?sort=-cf_date-of-birth` | Carla Verdi, Anna Rossi, Bruno Neri | + +Two rows are worth reading twice. `contains_any=latex` returns only Bruno because Anna +holds `pollen` alone, and `is_null=1` returns Carla because she has no `allergies` row at +all. The last row sorts every patient by date of birth descending, and nobody is dropped +for lacking a value. + +The rest of this section is the reference behind that example. + +### The whereCustomField scope + +The trait adds a scope for a direct equality lookup: + +```php +Patient::whereCustomField('risk-level', 'high')->get(); +``` + +It resolves an active definition, throws `UnknownCustomFieldException` when the slug is +unknown or inactive, and constrains the typed storage column of that field through a +`whereHas` on the value relation. It is equality only, and it passes the value to the query +as it is, so give it the stored shape: an option key for a select, a boolean for a boolean, +a `Y-m-d` string for a date. Everything richer belongs to the request filters below. + +### Filters and sorts for a request + +`CustomFields::filtersFor()` returns one `AllowedFilter` per declared query operation of +every active field, and `CustomFields::sortsFor()` returns an `AllowedSort` for every +active field whose type declares `sort`. Spatie expects them spread: + +```php +use App\Models\Patient; +use PlinCode\CustomFields\Facades\CustomFields; +use Spatie\QueryBuilder\QueryBuilder; + +$patients = QueryBuilder::for(Patient::class) ->allowedFilters(...CustomFields::filtersFor(Patient::class)) - ->allowedSorts(...CustomFields::sortsFor(Patient::class)); + ->allowedSorts(...CustomFields::sortsFor(Patient::class)) + ->paginate(); +``` + +Each of those calls reads the definitions from the database. When you need both, ask for +them together and pay for one query instead of two: + +```php +$options = CustomFields::queryOptionsFor(Patient::class); + +$patients = QueryBuilder::for(Patient::class) + ->allowedFilters(...$options['filters']) + ->allowedSorts(...$options['sorts']) + ->paginate(); +``` + +### The names a client sends + +A filter is named after the `key_prefix` and the slug. Equality keeps the bare name, and +every other operation is suffixed with a colon, a character a generated slug never +contains. Sorts always use the bare name. + +With the definitions above and the default `cf_` prefix, a client can send the following. +The line breaks are here for readability, a real request sends one line: + +```http +GET /api/patients + ?filter[cf_risk-level]=high + &filter[cf_risk-level:in]=high,low + &filter[cf_date-of-birth:between]=1980-01-01,1989-12-31 + &filter[cf_allergies:contains_any]=pollen,latex + &filter[cf_notes:contains]=follow%20up + &filter[cf_notes:is_null]=1 + &sort=-cf_date-of-birth +``` + +You can build the same names in your own code, for a link or for an OpenAPI document: + +```php +CustomFields::keyPrefix(); // 'cf_' +CustomFields::filterName('risk-level'); // 'cf_risk-level' +CustomFields::filterName('risk-level', 'in'); // 'cf_risk-level:in' +CustomFields::sortName('date-of-birth'); // 'cf_date-of-birth' +``` + +### The operations + +| Operation | Value the client sends | Meaning | +| --- | --- | --- | +| `equals` | a single value, or a list | Exact match. A list behaves as an `in`. | +| `in` | a comma separated list | The value is one of the given ones. | +| `contains` | a value, or a comma separated list | Case handling follows the collation of your database. `%`, `_`, `!` and a backslash are matched literally. A list matches any of the values. | +| `greater_than`, `less_than` | a single value | A strict comparison. A list is rejected. | +| `between` | exactly two comma separated values | An inclusive range. Any other count is rejected. | +| `is_null` | `1` to ask for absent, `0` to ask for present | A record with no value row and a record whose typed column is null both count as absent. | +| `is_not_null` | `1` to ask for present, `0` to ask for absent | The complement. A `multiselect` stored as an empty array counts as present. | +| `contains_any` | a comma separated list | The stored JSON array holds at least one of the values. | +| `contains_all` | a comma separated list | The stored JSON array holds all of the values. | + +A field only exposes the operations its type declares, so nothing arbitrary can be built +from a request. A `multiselect` never exposes a bare equality filter, and asking for +`filter[cf_allergies]` raises Spatie's `InvalidFilterQuery`. A `greater_than` given an array, +and a `between` given anything other than two values, raise Spatie's `InvalidFilterValue`. + +Records without a value for the sorted field come last in both directions, on MySQL, +PostgreSQL and SQLite alike. The sort is expressed as a correlated subquery, so your own +select, aggregates and existing order are left untouched and no row is duplicated. + +### Composing with other sorts + +Custom field sorts are ordinary `AllowedSort` instances, so they sit next to your own +columns and next to the sorts from `plin-code/laravel-eloquent-sorts`: + +```php +use App\Models\Patient; +use PlinCode\CustomFields\Facades\CustomFields; +use PlinCode\EloquentSorts\Sorts\RelationSorter; +use Spatie\QueryBuilder\AllowedSort; +use Spatie\QueryBuilder\QueryBuilder; + +$options = CustomFields::queryOptionsFor(Patient::class); + +$patients = QueryBuilder::for(Patient::class) + ->allowedFilters(...$options['filters']) + ->allowedSorts( + AllowedSort::field('last_name'), + AllowedSort::custom('clinic', new RelationSorter('clinics', 'clinic_id')), + ...$options['sorts'], + ) + ->paginate(); +``` + +A client then sends `?sort=clinic,-cf_date-of-birth` and gets the patients ordered by +clinic name and then by date of birth, newest first. + +## Building a form + +The package stores the metadata your UI needs and never renders it. Two things are exposed: +the registry of types, for the screen where an administrator defines a field, and the +definitions themselves, for the screen where a user fills them in. + +```php +use PlinCode\CustomFields\Facades\CustomFields; + +foreach (CustomFields::types() as $key => $type) { + $choice = [ + 'value' => $key, // 'select' + 'label' => $type->label(), // 'Select' + 'input' => $type->inputHint(), // 'select' + ]; +} ``` -The package is headless. It does not provide controllers, authorization or UI components. The product owns those layers and can iterate over `CustomFields::types()` to build its widget. +`inputHint()` is a suggestion, not a contract. It maps cleanly onto an HTML input type for +most of the built in types (`text`, `number`, `checkbox`, `date`, `datetime-local`) and +names a widget for the rest (`textarea`, `select`, `multiselect`). + +```php +use App\Models\Patient; +use PlinCode\CustomFields\Facades\CustomFields; + +$fieldModel = CustomFields::fieldModel(); + +$definitions = $fieldModel::query() + ->where('entity_type', CustomFields::entityKey(Patient::class)) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(); + +$values = $patient->getCustomFields(); + +$form = $definitions->map(fn ($field) => [ + 'name' => $field->slug, // 'risk-level' + 'label' => $field->name, // 'risk level' + 'required' => $field->is_required, // true + 'input' => $field->fieldType()->inputHint(), // 'select' + 'options' => $field->optionsForInput(), // [['key' => 'low', 'label' => 'Low', 'is_active' => true], ...] + 'rules' => $field->fieldType()->rules($field), // ['nullable', 'string', Rule::in([...])] + 'value' => $values[$field->slug], // 'high' +]); +``` + +Post the form back keyed by slug and hand the whole array to `setCustomFields()`. + +## Events + +Five events are dispatched, all of them after the surrounding transaction commits, since +each implements `Illuminate\Contracts\Events\ShouldDispatchAfterCommit`. + +| Event | Property | Dispatched when | +| --- | --- | --- | +| `PlinCode\CustomFields\Events\CustomFieldCreated` | `$field` | A definition is created. | +| `PlinCode\CustomFields\Events\CustomFieldUpdated` | `$field` | A definition is updated, including an option list change. | +| `PlinCode\CustomFields\Events\CustomFieldDeleted` | `$field` | A definition is deleted. | +| `PlinCode\CustomFields\Events\CustomFieldValueSaved` | `$value` | A value row is inserted or updated. | +| `PlinCode\CustomFields\Events\CustomFieldValueDeleted` | `$value` | A value row is removed, by `clearCustomField()`, by a `null` write, or by deleting the row yourself. | + +`$field` is a `CustomField` and `$value` is a `CustomFieldValue`, both public and readonly. +Use them to invalidate a cache, to reindex a search document, or to write an audit trail. + +## Custom field types -Custom types implement PlinCode\\CustomFields\\Contracts\\FieldType. Register a type -from a service provider with CustomFields::registerType(). A type owns its storage -column, validation rules, serialization, deserialization, defaults, labels, and -declared query operations. +A type implements `PlinCode\CustomFields\Contracts\FieldType` and owns its storage column, +its validation rules, its serialization, its default, its labels and the query operations it +declares. Extend one of the built in types when you only need to change part of that. + +```php + [ 'fields' => 'custom_fields', 'values' => 'custom_field_values', ], + /* + | The primary key type of both package tables. + | + | Supported values: "id" for auto incrementing big integers, "uuid", "ulid". + | The foreign key on the values table follows the same type. + | + | This must be set BEFORE you run the migrations. It cannot be changed + | afterwards without writing your own data migration. + */ 'key_type' => 'id', - 'morph_key_type' => 'uuid', + /* + | The key type of the models that own custom fields, used for the + | valuable_id column of the polymorphic relation. + | + | Supported values: "id" for auto incrementing big integers, "uuid", "ulid". + | Keep "id" for a default Laravel installation. Switch to "uuid" or "ulid" + | only when every model you attach custom fields to uses that key type. + | + | This must be set BEFORE you run the migrations. It cannot be changed + | afterwards without writing your own data migration. + */ + 'morph_key_type' => 'id', + + /* + | The Eloquent models the package resolves at runtime. + | + | Point these at your own subclasses to add relations, casts, a global + | scope or a dedicated database connection. Each replacement must extend + | the package model it replaces. + */ 'models' => [ 'custom_field' => CustomField::class, 'custom_field_value' => CustomFieldValue::class, ], + /* + | The prefix applied to the generated spatie/laravel-query-builder filter + | and sort names. + | + | With the default prefix a field with the slug "release_date" is exposed as + | filter[cf_release_date] and sort=cf_release_date. Change it to avoid a + | clash with the native columns you already expose. + */ 'key_prefix' => 'cf_', ]; diff --git a/database/migrations/2026_01_01_000000_create_laravel_custom_fields_placeholder_table.php b/database/migrations/2026_01_01_000000_create_custom_fields_table.php similarity index 100% rename from database/migrations/2026_01_01_000000_create_laravel_custom_fields_placeholder_table.php rename to database/migrations/2026_01_01_000000_create_custom_fields_table.php diff --git a/database/migrations/2026_01_01_000001_create_laravel_custom_field_values_table.php b/database/migrations/2026_01_01_000001_create_custom_field_values_table.php similarity index 99% rename from database/migrations/2026_01_01_000001_create_laravel_custom_field_values_table.php rename to database/migrations/2026_01_01_000001_create_custom_field_values_table.php index 55cb76d..77659a0 100644 --- a/database/migrations/2026_01_01_000001_create_laravel_custom_field_values_table.php +++ b/database/migrations/2026_01_01_000001_create_custom_field_values_table.php @@ -26,7 +26,7 @@ public function up(): void $table->foreignId('custom_field_id')->constrained($fieldsTable)->cascadeOnDelete(); } - match (config('laravel-custom-fields.morph_key_type', 'uuid')) { + match (config('laravel-custom-fields.morph_key_type', 'id')) { 'uuid' => $table->uuidMorphs('valuable'), 'ulid' => $table->ulidMorphs('valuable'), default => $table->morphs('valuable'), diff --git a/lang/en/messages.php b/lang/en/messages.php index ab90171..669acfc 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -4,6 +4,8 @@ return [ 'validation' => [ - 'invalid_option' => 'The selected option is invalid.', + 'invalid_option' => 'The selected option :option is invalid for :attribute.', + 'unknown_field' => 'The :attribute field is not defined for this model.', + 'inactive_field' => 'The :attribute field is not active.', ], ]; diff --git a/lang/it/messages.php b/lang/it/messages.php index f854892..027ccff 100644 --- a/lang/it/messages.php +++ b/lang/it/messages.php @@ -4,6 +4,8 @@ return [ 'validation' => [ - 'invalid_option' => 'L’opzione selezionata non è valida.', + 'invalid_option' => 'L’opzione selezionata :option non è valida per :attribute.', + 'unknown_field' => 'Il campo :attribute non è definito per questo modello.', + 'inactive_field' => 'Il campo :attribute non è attivo.', ], ]; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d68bb9d..2028631 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,6 +4,5 @@ parameters: - src - config - database + - workbench tmpDir: build/phpstan - excludePaths: - - src/Concerns/HasCustomFields.php diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..158e251 --- /dev/null +++ b/rector.php @@ -0,0 +1,56 @@ +withPaths([ + __DIR__.'/config', + __DIR__.'/database', + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withPhpSets() + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + ) + ->withSets([ + LaravelSetList::LARAVEL_CODE_QUALITY, + ]) + ->withSkip([ + // The package resolves out of the container with app(), which is the + // convention the rest of the Laravel ecosystem reads. + AppToResolveRector::class, + + // HasCustomFields is a trait consumers apply to their own models, and + // whereCustomField is documented public API. The rule assumes a model. + MakeModelAttributesAndScopesProtectedRector::class => [ + __DIR__.'/src/Concerns/HasCustomFields.php', + ], + + // Architecture tests describe namespaces and classes as strings on + // purpose, so the string form is the point rather than an oversight. + StringClassNameToClassConstantRector::class => [ + __DIR__.'/tests/ArchTest.php', + ], + + // Pest test bodies keep the explicit call forms the suite was written + // with, so a style pass never rewrites an assertion out from under it. + FunctionFirstClassCallableRector::class => [ + __DIR__.'/tests', + ], + + // This one rewrites at print time rather than through the rule, so + // Rector attributes no rule to the change and a path scoped skip never + // matches it. Skipping it outright is the only form that holds. + NewMethodCallWithoutParenthesesRector::class, + ]); diff --git a/src/Concerns/HasCustomFields.php b/src/Concerns/HasCustomFields.php index c89d724..c486c6c 100644 --- a/src/Concerns/HasCustomFields.php +++ b/src/Concerns/HasCustomFields.php @@ -7,39 +7,60 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Facades\DB; -use InvalidArgumentException; +use PlinCode\CustomFields\Exceptions\ModelNotPersistedException; +use PlinCode\CustomFields\Exceptions\UnknownCustomFieldException; use PlinCode\CustomFields\Facades\CustomFields; +use PlinCode\CustomFields\Models\CustomField; +use PlinCode\CustomFields\Models\CustomFieldValue; trait HasCustomFields { + /** @var array */ + private const array STORAGE_COLUMNS = [ + 'value_string', + 'value_text', + 'value_integer', + 'value_decimal', + 'value_boolean', + 'value_date', + 'value_datetime', + 'value_json', + ]; + + /** @return MorphMany */ public function customFieldValues(): MorphMany { - return $this->morphMany(CustomFields::valueModel(), 'valuable'); + /** @var class-string $model */ + $model = CustomFields::valueModel(); + + return $this->morphMany($model, 'valuable'); } - public function getCustomField(string $slug): mixed + /** + * Reads an active definition. Pass includeInactive to read a value that + * belongs to a definition the product has deactivated. + */ + public function getCustomField(string $slug, bool $includeInactive = false): mixed { - $field = $this->customFieldDefinition($slug); - $value = $this->customFieldValues()->where('custom_field_id', $field->getKey())->first(); + $field = $this->customFieldDefinition($slug, $includeInactive); + $row = $this->customFieldValues()->where('custom_field_id', $field->getKey())->first(); + $row?->setRelation('customField', $field); - return $value?->getValue() ?? $field->fieldType()->default(); + return $row?->getValue() ?? $field->fieldType()->default(); } /** @return array */ public function getCustomFields(bool $includeInactive = false): array { - $entityKey = CustomFields::entityKey($this); - $fieldModel = CustomFields::fieldModel(); - $fields = $fieldModel::query()->where('entity_type', $entityKey) - ->when(! $includeInactive, fn (Builder $query): Builder => $query->where('is_active', true)) - ->get(); - $values = $this->customFieldValues()->with('customField')->get()->keyBy('custom_field_id'); + $definitions = $this->customFieldDefinitions($includeInactive); + $rows = $this->customFieldRows($definitions); + $values = []; - return $fields->mapWithKeys(function ($field) use ($values): array { - $value = $values->get($field->getKey()); + foreach ($definitions as $slug => $field) { + $values[$slug] = $rows[$slug]?->getValue() ?? $field->fieldType()->default(); + } - return [$field->slug => $value?->getValue() ?? $field->fieldType()->default()]; - })->all(); + return $values; } public function setCustomField(string $slug, mixed $value): void @@ -47,25 +68,107 @@ public function setCustomField(string $slug, mixed $value): void $this->setCustomFields([$slug => $value]); } - /** @param array $values */ + /** + * Validates the whole batch before writing it. A null value removes the + * stored row inside the same transaction as the rest of the batch. + * + * @param array $values + */ public function setCustomFields(array $values, bool $complete = false): void { - CustomFields::validate($this, $values, $complete); + if (! $this->exists || $this->getKey() === null) { + throw ModelNotPersistedException::for(static::class); + } + + $definitions = $this->customFieldDefinitions(includeInactive: true); + $rows = $this->customFieldRows($definitions); + $stored = []; + + foreach ($rows as $slug => $row) { + if ($row instanceof CustomFieldValue) { + $stored[$slug] = $row->getValue(); + } + } + + CustomFields::validator()->validate($this, $values, $complete, $definitions, $stored); + + /** @var class-string $valueModel */ $valueModel = CustomFields::valueModel(); $connection = $valueModel::query()->getModel()->getConnectionName(); - DB::connection($connection)->transaction(function () use ($values): void { - foreach ($values as $slug => $value) { - $this->writeCustomField((string) $slug, $value); + DB::connection($connection)->transaction(function () use ($values, $definitions, $rows): void { + foreach ($values as $key => $value) { + $slug = (string) $key; + $field = $definitions[$slug]; + $row = $rows[$slug] ?? null; + + if ($value === null) { + $row?->delete(); + + continue; + } + + $this->writeCustomField($field, $row, $value); } }); + + $this->unsetRelation('customFieldValues'); } - private function writeCustomField(string $slug, mixed $value): void + /** + * Removes a stored value. Definitions the product deactivated stay + * clearable, otherwise their values could never be removed. + */ + public function clearCustomField(string $slug): void + { + $field = $this->customFieldDefinition($slug, includeInactive: true); + $row = $this->customFieldValues()->where('custom_field_id', $field->getKey())->first(); + + if ($row === null) { + return; + } + + $row->setRelation('customField', $field); + $row->delete(); + $this->unsetRelation('customFieldValues'); + } + + /** + * Equality lookup on one active definition. The value reaches the storage + * column through the field type, so the scope compares what a write stores + * and what a request filter compares. A field stored as json is matched by + * containment, because equality against a json array never holds. + * + * @param Builder $query + * @return Builder + */ + public function scopeWhereCustomField(Builder $query, string $slug, mixed $value): Builder { $field = $this->customFieldDefinition($slug); - $model = CustomFields::valueModel(); - $row = $model::query()->firstOrNew([ + $fieldKey = $field->getKey(); + $type = $field->fieldType(); + $column = $type->storageColumn(); + $json = $column === 'value_json'; + $comparable = $json ? $value : $type->serialize($value, $field); + + return $query->whereHas('customFieldValues', function (Builder $inner) use ($fieldKey, $column, $comparable, $json): void { + $inner->where('custom_field_id', $fieldKey); + + if ($json) { + $inner->whereJsonContains($column, $comparable); + + return; + } + + $inner->where($column, $comparable); + }); + } + + private function writeCustomField(CustomField $field, ?CustomFieldValue $row, mixed $value): void + { + /** @var class-string $valueModel */ + $valueModel = CustomFields::valueModel(); + $row ??= $valueModel::query()->make([ 'custom_field_id' => $field->getKey(), 'valuable_type' => $this->getMorphClass(), 'valuable_id' => $this->getKey(), @@ -73,39 +176,84 @@ private function writeCustomField(string $slug, mixed $value): void $row->setRelation('customField', $field); $column = $field->fieldType()->storageColumn(); - foreach (['value_string', 'value_text', 'value_integer', 'value_decimal', 'value_boolean', 'value_date', 'value_datetime', 'value_json'] as $storageColumn) { + foreach (self::STORAGE_COLUMNS as $storageColumn) { $row->setAttribute($storageColumn, $storageColumn === $column ? $field->fieldType()->serialize($value, $field) : null); } $row->save(); } - public function clearCustomField(string $slug): void + /** + * Loads the definitions of the entity in a single query. + * + * @return array + */ + private function customFieldDefinitions(bool $includeInactive = false): array { - $field = $this->customFieldDefinition($slug); - $this->customFieldValues()->where('custom_field_id', $field->getKey())->delete(); + /** @var class-string $fieldModel */ + $fieldModel = CustomFields::fieldModel(); + $query = $fieldModel::query()->where('entity_type', CustomFields::entityKey($this)); + + if (! $includeInactive) { + $query->where('is_active', true); + } + + return $query->get() + ->keyBy(static fn (CustomField $field): string => (string) $field->getAttribute('slug')) + ->all(); } - public function scopeWhereCustomField(Builder $query, string $slug, mixed $value): Builder + /** + * Loads the stored rows in a single query and attaches the definitions + * already in memory, so a read never queries a field per value. + * + * @param array $definitions + * @return array + */ + private function customFieldRows(array $definitions): array { - $field = $this->customFieldDefinition($slug); + /** @var array $rows */ + $rows = array_fill_keys(array_keys($definitions), null); - return $query->whereHas('customFieldValues', function (Builder $inner) use ($field, $value): void { - $inner->where('custom_field_id', $field->getKey()) - ->where($field->fieldType()->storageColumn(), $value); - }); + if (! $this->exists || $this->getKey() === null) { + return $rows; + } + + $slugs = []; + + foreach ($definitions as $slug => $field) { + $slugs[(string) $field->getKey()] = $slug; + } + + foreach ($this->customFieldValues()->get() as $row) { + $slug = $slugs[(string) $row->getAttribute('custom_field_id')] ?? null; + + if ($slug === null) { + continue; + } + + $row->setRelation('customField', $definitions[$slug]); + $rows[$slug] = $row; + } + + return $rows; } - private function customFieldDefinition(string $slug): object + private function customFieldDefinition(string $slug, bool $includeInactive = false): CustomField { - $field = CustomFields::fieldModel()::query() + /** @var class-string $fieldModel */ + $fieldModel = CustomFields::fieldModel(); + $field = $fieldModel::query() ->where('entity_type', CustomFields::entityKey($this)) ->where('slug', $slug) - ->where('is_active', true) ->first(); if ($field === null) { - throw new InvalidArgumentException("Custom field [{$slug}] is not defined for this model."); + throw UnknownCustomFieldException::slug($slug, static::class); + } + + if (! $field->getAttribute('is_active') && ! $includeInactive) { + throw UnknownCustomFieldException::inactive($slug, static::class); } return $field; diff --git a/src/Contracts/FieldType.php b/src/Contracts/FieldType.php index 0c472a1..f4ea533 100644 --- a/src/Contracts/FieldType.php +++ b/src/Contracts/FieldType.php @@ -25,6 +25,14 @@ public function label(): string; public function inputHint(): string; - /** @return array */ + /** + * Query operations the type can actually serve. + * + * The vocabulary of the package is equals, in, contains, greater_than, less_than, + * between, is_null, is_not_null, contains_any, contains_all and sort. A consumer + * type may declare its own operation, and then it also provides the filter for it. + * + * @return array + */ public function queryOperations(): array; } diff --git a/src/CustomFields.php b/src/CustomFields.php index 85b2eaa..c6a6f23 100644 --- a/src/CustomFields.php +++ b/src/CustomFields.php @@ -4,6 +4,7 @@ namespace PlinCode\CustomFields; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Str; @@ -110,33 +111,132 @@ public function validate(Model $model, array $values, bool $complete = false): v $this->validator()->validate($model, $values, $complete); } - /** @return array */ + /** + * Filters for every active field of the host, one per declared query operation. + * + * This reads the definitions from the database, so call it while serving a request + * and not while a service provider boots. + * + * @return array + */ public function filtersFor(Model|string $model): array { - $fieldModel = $this->fieldModel(); - $entityKey = $this->entityKey($model); - - return $fieldModel::query()->where('entity_type', $entityKey)->where('is_active', true)->get() - ->map(fn (Model $field): AllowedFilter => AllowedFilter::custom( - 'cf_'.$field->getAttribute('slug'), - new CustomFieldFilter($field), - ))->all(); + return $this->buildFilters($this->queryableFields($model)); } - /** @return array */ + /** + * Sorts for every active field of the host that declares the sort operation. + * + * This reads the definitions from the database, so call it while serving a request + * and not while a service provider boots. + * + * @return array + */ public function sortsFor(Model|string $model): array { - $fieldModel = $this->fieldModel(); - $entityKey = $this->entityKey($model); - - return $fieldModel::query()->where('entity_type', $entityKey)->where('is_active', true)->get() - ->filter(function (Model $field): bool { - /** @var CustomField $field */ - return in_array('sort', $field->fieldType()->queryOperations(), true); - }) - ->map(fn (Model $field): AllowedSort => AllowedSort::custom( - 'cf_'.$field->getAttribute('slug'), + return $this->buildSorts($this->queryableFields($model)); + } + + /** + * Filters and sorts of the host, reading the definitions once. + * + * @return array{filters: array, sorts: array} + */ + public function queryOptionsFor(Model|string $model): array + { + $fields = $this->queryableFields($model); + + return [ + 'filters' => $this->buildFilters($fields), + 'sorts' => $this->buildSorts($fields), + ]; + } + + /** Prefix of every filter and sort name exposed to a request. */ + public function keyPrefix(): string + { + return (string) config('laravel-custom-fields.key_prefix', 'cf_'); + } + + /** + * Name of the filter for a field slug and one query operation. + * + * Equality keeps the bare name, every other operation is suffixed with a colon, + * a character a generated slug never contains. + */ + public function filterName(string $slug, string $operation = CustomFieldFilter::EQUALS): string + { + $name = $this->keyPrefix().$slug; + + return $operation === CustomFieldFilter::EQUALS ? $name : $name.':'.$operation; + } + + public function sortName(string $slug): string + { + return $this->keyPrefix().$slug; + } + + /** + * @param Collection $fields + * @return array + */ + private function buildFilters(Collection $fields): array + { + $filters = []; + + foreach ($fields as $field) { + /** @var CustomField $field */ + $slug = (string) $field->getAttribute('slug'); + + foreach ($field->fieldType()->queryOperations() as $operation) { + if (! CustomFieldFilter::supports($operation)) { + continue; + } + + $filters[] = AllowedFilter::custom( + $this->filterName($slug, $operation), + new CustomFieldFilter($field, $operation), + ); + } + } + + return $filters; + } + + /** + * @param Collection $fields + * @return array + */ + private function buildSorts(Collection $fields): array + { + $sorts = []; + + foreach ($fields as $field) { + /** @var CustomField $field */ + if (! in_array(CustomFieldSorter::SORT, $field->fieldType()->queryOperations(), true)) { + continue; + } + + $sorts[] = AllowedSort::custom( + $this->sortName((string) $field->getAttribute('slug')), new CustomFieldSorter($field), - ))->all(); + ); + } + + return $sorts; + } + + /** @return Collection */ + private function queryableFields(Model|string $model): Collection + { + $fieldModel = $this->fieldModel(); + + /** @var Collection $fields */ + $fields = $fieldModel::query() + ->where('entity_type', $this->entityKey($model)) + ->where('is_active', true) + ->get(); + + return $fields; } } diff --git a/src/CustomFieldsServiceProvider.php b/src/CustomFieldsServiceProvider.php index 0196842..8f49219 100644 --- a/src/CustomFieldsServiceProvider.php +++ b/src/CustomFieldsServiceProvider.php @@ -4,7 +4,6 @@ namespace PlinCode\CustomFields; -use Illuminate\Support\ServiceProvider; use InvalidArgumentException; use PlinCode\CustomFields\Types\BooleanType; use PlinCode\CustomFields\Types\DateTimeType; @@ -18,21 +17,73 @@ use PlinCode\CustomFields\Types\TextareaType; use PlinCode\CustomFields\Types\TextType; use PlinCode\CustomFields\Types\UrlType; +use Spatie\LaravelPackageTools\Package; +use Spatie\LaravelPackageTools\PackageServiceProvider; -class CustomFieldsServiceProvider extends ServiceProvider +class CustomFieldsServiceProvider extends PackageServiceProvider { /** - * Register any application services. + * The package name, used as the config key, the translation namespace + * and the prefix of every publish tag. */ - public function register(): void + private const string PACKAGE_NAME = 'laravel-custom-fields'; + + /** + * The field types registered on the manager the first time it is resolved. + */ + private const array BUILT_IN_TYPES = [ + TextType::class, + TextareaType::class, + EmailType::class, + UrlType::class, + PhoneType::class, + NumberType::class, + DecimalType::class, + BooleanType::class, + DateType::class, + DateTimeType::class, + SelectType::class, + MultiSelectType::class, + ]; + + public function configurePackage(Package $package): void + { + $package + ->name(self::PACKAGE_NAME) + ->hasConfigFile(self::PACKAGE_NAME) + ->hasTranslations() + ->hasMigrations([ + '2026_01_01_000000_create_custom_fields_table', + '2026_01_01_000001_create_custom_field_values_table', + ]); + } + + /** + * Keep the package short name equal to the package name. + * + * Package tools strips the "laravel-" prefix by default, which would turn the + * translation namespace into "custom-fields" and the publish tags into + * "custom-fields-config" and "custom-fields-migrations". + */ + public function newPackage(): Package + { + return new class extends Package + { + public function shortName(): string + { + return $this->name; + } + }; + } + + public function packageRegistered(): void { - $this->mergeConfigFrom(__DIR__.'/../config/laravel-custom-fields.php', 'laravel-custom-fields'); $this->validateKeyConfiguration(); $this->app->singleton(CustomFields::class); $this->app->afterResolving(CustomFields::class, function (CustomFields $manager): void { - foreach ([TextType::class, TextareaType::class, EmailType::class, UrlType::class, PhoneType::class, NumberType::class, DecimalType::class, BooleanType::class, DateType::class, DateTimeType::class, SelectType::class, MultiSelectType::class] as $type) { + foreach (self::BUILT_IN_TYPES as $type) { try { $manager->registerType($type); } catch (InvalidArgumentException) { @@ -42,36 +93,53 @@ public function register(): void }); } - private function validateKeyConfiguration(): void + /** + * Group the config file and the migrations under the umbrella publish tag as well, + * so "vendor:publish --tag=laravel-custom-fields" keeps publishing everything. + */ + public function packageBooted(): void { - foreach (['key_type', 'morph_key_type'] as $key) { - if (! in_array(config('laravel-custom-fields.'.$key), ['id', 'uuid', 'ulid'], true)) { - throw new InvalidArgumentException("Unsupported custom fields key type [{$key}]."); - } + if (! $this->app->runningInConsole()) { + return; + } + + foreach ([self::PACKAGE_NAME.'-config', self::PACKAGE_NAME.'-migrations'] as $tag) { + $this->publishes(static::pathsToPublish(static::class, $tag), self::PACKAGE_NAME); } } /** - * Bootstrap any application services. + * Load and publish the translations. + * + * Package tools expects them under resources/lang, this package keeps them in lang, + * and the published tag has to stay "laravel-custom-fields-lang". */ - public function boot(): void + protected function bootPackageTranslations(): self { - $this->loadTranslationsFrom(__DIR__.'/../lang', 'laravel-custom-fields'); - - if (! $this->app->runningInConsole()) { - return; + if (! $this->package->hasTranslations) { + return $this; } - $this->publishes([ - __DIR__.'/../config/laravel-custom-fields.php' => config_path('laravel-custom-fields.php'), - ], ['laravel-custom-fields', 'laravel-custom-fields-config']); + $packageTranslations = $this->package->basePath('/../lang'); + + $this->loadTranslationsFrom($packageTranslations, $this->package->shortName()); - $this->publishes([ - __DIR__.'/../lang' => $this->app->langPath('vendor/laravel-custom-fields'), - ], ['laravel-custom-fields', 'laravel-custom-fields-lang']); + if ($this->app->runningInConsole()) { + $this->publishes( + [$packageTranslations => $this->app->langPath('vendor/'.$this->package->shortName())], + [self::PACKAGE_NAME, self::PACKAGE_NAME.'-lang'], + ); + } + + return $this; + } - $this->publishesMigrations([ - __DIR__.'/../database/migrations' => database_path('migrations'), - ], ['laravel-custom-fields', 'laravel-custom-fields-migrations']); + private function validateKeyConfiguration(): void + { + foreach (['key_type', 'morph_key_type'] as $key) { + if (! in_array(config(self::PACKAGE_NAME.'.'.$key), ['id', 'uuid', 'ulid'], true)) { + throw new InvalidArgumentException("Unsupported custom fields key type [{$key}]."); + } + } } } diff --git a/src/Events/CustomFieldCreated.php b/src/Events/CustomFieldCreated.php index 50877c4..ff1e54e 100644 --- a/src/Events/CustomFieldCreated.php +++ b/src/Events/CustomFieldCreated.php @@ -7,7 +7,7 @@ use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use PlinCode\CustomFields\Models\CustomField; -final class CustomFieldCreated implements ShouldDispatchAfterCommit +final readonly class CustomFieldCreated implements ShouldDispatchAfterCommit { - public function __construct(public readonly CustomField $field) {} + public function __construct(public CustomField $field) {} } diff --git a/src/Events/CustomFieldDeleted.php b/src/Events/CustomFieldDeleted.php index 9c4f107..41aa10b 100644 --- a/src/Events/CustomFieldDeleted.php +++ b/src/Events/CustomFieldDeleted.php @@ -7,7 +7,7 @@ use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use PlinCode\CustomFields\Models\CustomField; -final class CustomFieldDeleted implements ShouldDispatchAfterCommit +final readonly class CustomFieldDeleted implements ShouldDispatchAfterCommit { - public function __construct(public readonly CustomField $field) {} + public function __construct(public CustomField $field) {} } diff --git a/src/Events/CustomFieldUpdated.php b/src/Events/CustomFieldUpdated.php index 5bb068d..b0d683e 100644 --- a/src/Events/CustomFieldUpdated.php +++ b/src/Events/CustomFieldUpdated.php @@ -7,7 +7,7 @@ use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use PlinCode\CustomFields\Models\CustomField; -final class CustomFieldUpdated implements ShouldDispatchAfterCommit +final readonly class CustomFieldUpdated implements ShouldDispatchAfterCommit { - public function __construct(public readonly CustomField $field) {} + public function __construct(public CustomField $field) {} } diff --git a/src/Events/CustomFieldValueDeleted.php b/src/Events/CustomFieldValueDeleted.php index 0934522..c9a33ef 100644 --- a/src/Events/CustomFieldValueDeleted.php +++ b/src/Events/CustomFieldValueDeleted.php @@ -7,7 +7,7 @@ use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use PlinCode\CustomFields\Models\CustomFieldValue; -final class CustomFieldValueDeleted implements ShouldDispatchAfterCommit +final readonly class CustomFieldValueDeleted implements ShouldDispatchAfterCommit { - public function __construct(public readonly CustomFieldValue $value) {} + public function __construct(public CustomFieldValue $value) {} } diff --git a/src/Events/CustomFieldValueSaved.php b/src/Events/CustomFieldValueSaved.php index 0b4227b..1fae9ac 100644 --- a/src/Events/CustomFieldValueSaved.php +++ b/src/Events/CustomFieldValueSaved.php @@ -7,7 +7,7 @@ use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use PlinCode\CustomFields\Models\CustomFieldValue; -final class CustomFieldValueSaved implements ShouldDispatchAfterCommit +final readonly class CustomFieldValueSaved implements ShouldDispatchAfterCommit { - public function __construct(public readonly CustomFieldValue $value) {} + public function __construct(public CustomFieldValue $value) {} } diff --git a/src/Exceptions/ModelNotPersistedException.php b/src/Exceptions/ModelNotPersistedException.php new file mode 100644 index 0000000..6dcc694 --- /dev/null +++ b/src/Exceptions/ModelNotPersistedException.php @@ -0,0 +1,16 @@ + types() + * @method static void registerEntity(class-string $model, string $key, string|null $label = null) + * @method static array entities() + * @method static string entityKey(Model|string $model) + * @method static string fieldModel() + * @method static string valueModel() + * @method static ValueValidator validator() + * @method static void validate(Model $model, array $values, bool $complete = false) + * @method static array filtersFor(Model|string $model) + * @method static array sortsFor(Model|string $model) + * @method static array{filters: array, sorts: array} queryOptionsFor(Model|string $model) + * @method static string keyPrefix() + * @method static string filterName(string $slug, string $operation = CustomFieldFilter::EQUALS) + * @method static string sortName(string $slug) + * * @see \PlinCode\CustomFields\CustomFields */ class CustomFields extends Facade diff --git a/src/Models/CustomField.php b/src/Models/CustomField.php index b94e6b2..2bdfcc3 100644 --- a/src/Models/CustomField.php +++ b/src/Models/CustomField.php @@ -81,6 +81,32 @@ public function optionsForInput(): array )); } + /** + * Every option key defined on the field, including the inactive ones. + * + * @return array + */ + public function optionKeys(): array + { + return array_values(array_map( + static fn (array $option): string => (string) ($option['key'] ?? ''), + (array) $this->getAttribute('options'), + )); + } + + /** + * The option keys that can still be assigned to a value. + * + * @return array + */ + public function activeOptionKeys(): array + { + return array_values(array_map( + static fn (array $option): string => (string) $option['key'], + $this->optionsForInput(), + )); + } + /** @param array $options */ public function updateOptions(array $options): self { diff --git a/src/Observers/CustomFieldObserver.php b/src/Observers/CustomFieldObserver.php index 48e4fc6..2294e0c 100644 --- a/src/Observers/CustomFieldObserver.php +++ b/src/Observers/CustomFieldObserver.php @@ -94,7 +94,8 @@ private function slug(string $name, string $entityType): string while ($model::query()->where('entity_type', $entityType)->where('slug', $slug)->exists()) { $suffixText = '-'.$suffix++; - $slug = mb_substr($base, 0, 100 - mb_strlen($suffixText)).$suffixText; + $trimmed = rtrim(mb_substr($base, 0, max(1, 100 - mb_strlen($suffixText))), '-'); + $slug = mb_substr($trimmed.$suffixText, 0, 100); } return $slug; diff --git a/src/Query/CustomFieldFilter.php b/src/Query/CustomFieldFilter.php index cd02e21..d5ea96b 100644 --- a/src/Query/CustomFieldFilter.php +++ b/src/Query/CustomFieldFilter.php @@ -6,31 +6,296 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Query\Expression; +use InvalidArgumentException; +use PlinCode\CustomFields\Contracts\FieldType; use PlinCode\CustomFields\Models\CustomField; +use Spatie\QueryBuilder\Exceptions\InvalidFilterValue; use Spatie\QueryBuilder\Filters\Filter; -/** @implements Filter */ +/** + * Applies a single declared query operation of a custom field. + * + * The operation always comes from the field type registry, never from the request, + * and a field type that does not declare the operation is refused at construction. + * + * @implements Filter + */ class CustomFieldFilter implements Filter { - public function __construct(private readonly Model $field) {} + public const string EQUALS = 'equals'; + + public const string IN = 'in'; + + public const string CONTAINS = 'contains'; + + public const string GREATER_THAN = 'greater_than'; + + public const string LESS_THAN = 'less_than'; + + public const string BETWEEN = 'between'; + + public const string IS_NULL = 'is_null'; + + public const string IS_NOT_NULL = 'is_not_null'; + + public const string CONTAINS_ANY = 'contains_any'; + + public const string CONTAINS_ALL = 'contains_all'; + + /** + * Escape character for the contains operation. + * + * A backslash is not usable here because MySQL, PostgreSQL and SQLite disagree on + * how a backslash survives a string literal, so a neutral character is used and the + * escape clause is always written explicitly. + */ + private const string LIKE_ESCAPE = '!'; + + private readonly string $operation; + + public function __construct(private readonly Model $field, string $operation = self::EQUALS) + { + if (! self::supports($operation)) { + throw new InvalidArgumentException("Custom field query operation [{$operation}] is not supported."); + } + + if (! in_array($operation, $this->fieldType()->queryOperations(), true)) { + throw new InvalidArgumentException(sprintf( + 'Custom field [%s] does not declare the query operation [%s].', + (string) $this->field->getAttribute('slug'), + $operation, + )); + } + + $this->operation = $operation; + } + + /** + * Operations this filter can apply. + * + * @return array + */ + public static function operations(): array + { + return [ + self::EQUALS, + self::IN, + self::CONTAINS, + self::GREATER_THAN, + self::LESS_THAN, + self::BETWEEN, + self::IS_NULL, + self::IS_NOT_NULL, + self::CONTAINS_ANY, + self::CONTAINS_ALL, + ]; + } + + public static function supports(string $operation): bool + { + return in_array($operation, self::operations(), true); + } + + public function operation(): string + { + return $this->operation; + } public function __invoke(Builder $query, mixed $value, string $property): void { - /** @var CustomField $field */ - $field = $this->field; - $column = $field->fieldType()->storageColumn(); - $fieldId = $field->getKey(); + if ($this->operation === self::IS_NULL) { + $this->applyPresence($query, ! $this->flag($value)); + + return; + } + + if ($this->operation === self::IS_NOT_NULL) { + $this->applyPresence($query, $this->flag($value)); - $query->whereHas('customFieldValues', function (Builder $inner) use ($column, $fieldId, $value): void { - $inner->where('custom_field_id', $fieldId); + return; + } - if (is_array($value)) { - $inner->whereIn($column, $value); + $query->whereHas('customFieldValues', function (Builder $inner) use ($value): void { + $this->applyValue($inner, $value); + }); + } + + /** @param Builder $inner */ + private function applyValue(Builder $inner, mixed $value): void + { + $values = $inner->getModel(); + $column = $values->qualifyColumn($this->fieldType()->storageColumn()); + + $inner->where($values->qualifyColumn('custom_field_id'), $this->field->getKey()); - return; + match ($this->operation) { + self::EQUALS => is_array($value) + ? $inner->whereIn($column, $this->castMany($value)) + : $inner->where($column, $this->cast($value)), + self::IN => $inner->whereIn($column, $this->castMany($this->many($value))), + self::CONTAINS => $this->applyContains($inner, $column, $value), + self::GREATER_THAN => $inner->where($column, '>', $this->cast($this->single($value))), + self::LESS_THAN => $inner->where($column, '<', $this->cast($this->single($value))), + self::BETWEEN => $inner->whereBetween($column, $this->range($value)), + self::CONTAINS_ANY => $this->applyJsonContains($inner, $column, $value, false), + self::CONTAINS_ALL => $this->applyJsonContains($inner, $column, $value, true), + default => throw new InvalidArgumentException("Custom field query operation [{$this->operation}] cannot filter values."), + }; + } + + /** + * The escape character has no builder method, so the placeholder and the escape + * clause travel together as an expression and the pattern is bound right after the + * clause that uses it, which keeps the bindings in the order of the compiled SQL. + * + * Case sensitivity follows the collation of the database, so the same pattern can + * behave differently on MySQL and on PostgreSQL. + * + * @param Builder $inner + */ + private function applyContains(Builder $inner, string $column, mixed $value): void + { + $pattern = new Expression("? escape '".self::LIKE_ESCAPE."'"); + + $inner->where(function (Builder $group) use ($column, $pattern, $value): void { + foreach ($this->many($value) as $item) { + $group->orWhere($column, 'like', $pattern) + ->addBinding('%'.$this->escapeLike($this->text($item)).'%', 'where'); } + }); + } + + /** @param Builder $inner */ + private function applyJsonContains(Builder $inner, string $column, mixed $value, bool $all): void + { + $values = $this->many($value); + + if ($values === []) { + return; + } + + if ($all) { + foreach ($values as $item) { + $inner->whereJsonContains($column, $item); + } + + return; + } - $inner->where($column, $value); + $inner->where(function (Builder $group) use ($column, $values): void { + foreach ($values as $item) { + $group->orWhereJsonContains($column, $item); + } }); } + + /** + * A row is present when it exists and its typed column is not null, so a host + * without any value row and a host with a null value are both absent. + * + * @param Builder $query + */ + private function applyPresence(Builder $query, bool $present): void + { + $constraint = function (Builder $inner): void { + $values = $inner->getModel(); + + $inner->where($values->qualifyColumn('custom_field_id'), $this->field->getKey()) + ->whereNotNull($values->qualifyColumn($this->fieldType()->storageColumn())); + }; + + if ($present) { + $query->whereHas('customFieldValues', $constraint); + + return; + } + + $query->whereDoesntHave('customFieldValues', $constraint); + } + + /** + * Reads the request value of a presence filter as a switch, so a falsy value asks + * for the opposite of the operation. + */ + private function flag(mixed $value): bool + { + return filter_var($this->many($value)[0] ?? null, FILTER_VALIDATE_BOOLEAN); + } + + private function escapeLike(string $value): string + { + return str_replace( + [self::LIKE_ESCAPE, '%', '_'], + [self::LIKE_ESCAPE.self::LIKE_ESCAPE, self::LIKE_ESCAPE.'%', self::LIKE_ESCAPE.'_'], + $value, + ); + } + + /** @return array */ + private function many(mixed $value): array + { + return is_array($value) ? array_values($value) : [$value]; + } + + private function single(mixed $value): mixed + { + if (is_array($value)) { + throw InvalidFilterValue::make($this->describe($value)); + } + + return $value; + } + + /** @return array */ + private function range(mixed $value): array + { + $values = is_array($value) ? array_values($value) : explode(',', $this->text($value)); + + if (count($values) !== 2) { + throw InvalidFilterValue::make($this->describe($value)); + } + + return $this->castMany($values); + } + + private function cast(mixed $value): mixed + { + return $value === null ? null : $this->fieldType()->serialize($value, $this->field); + } + + /** + * @param array $values + * @return array + */ + private function castMany(array $values): array + { + return array_values(array_map($this->cast(...), $values)); + } + + private function text(mixed $value): string + { + if (! is_scalar($value)) { + throw InvalidFilterValue::make($this->describe($value)); + } + + return (string) $value; + } + + private function describe(mixed $value): string + { + if (is_array($value)) { + return implode(',', array_map($this->describe(...), $value)); + } + + return is_scalar($value) ? (string) $value : get_debug_type($value); + } + + private function fieldType(): FieldType + { + /** @var CustomField $field */ + $field = $this->field; + + return $field->fieldType(); + } } diff --git a/src/Query/CustomFieldSorter.php b/src/Query/CustomFieldSorter.php index 52e072f..2d6ee3e 100644 --- a/src/Query/CustomFieldSorter.php +++ b/src/Query/CustomFieldSorter.php @@ -6,33 +6,103 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Query\Builder as QueryBuilder; +use InvalidArgumentException; use PlinCode\CustomFields\Facades\CustomFields; use PlinCode\CustomFields\Models\CustomField; +use PlinCode\EloquentSorts\Support\Direction; use Spatie\QueryBuilder\Sorts\Sort; -/** @implements Sort */ +/** + * Orders hosts by the typed column of one custom field. + * + * The order is expressed as a correlated subquery so the select, the aggregates and + * the ordering of the caller stay untouched and no row is duplicated. Hosts without a + * value, and hosts whose value is null, always come last in both directions, because + * MySQL, PostgreSQL and SQLite do not agree on where a null belongs. + * + * @implements Sort + */ class CustomFieldSorter implements Sort { - public function __construct( - private readonly Model $field, - ) {} + /** Operation a field type declares when it can be ordered on. */ + public const string SORT = 'sort'; + + public function __construct(private readonly Model $field) + { + /** @var CustomField $field */ + $field = $this->field; + + if (! in_array(self::SORT, $field->fieldType()->queryOperations(), true)) { + throw new InvalidArgumentException(sprintf( + 'Custom field [%s] does not declare the query operation [%s].', + (string) $field->getAttribute('slug'), + self::SORT, + )); + } + } public function __invoke(Builder $query, bool $descending, string $property): void + { + $host = $query->getModel(); + + // The direction is normalised by the sibling sorts package, the same way that + // package rejects a direction it does not know. The comparison that follows + // only narrows the validated string back to the two literals Eloquent accepts. + $direction = Direction::normalise($descending ? 'desc' : 'asc'); + + $query->orderBy($this->presence($host), 'desc') + ->orderBy($this->values($host), $direction === 'desc' ? 'desc' : 'asc'); + } + + /** + * Counts the value row of the field, so a host without a value scores zero and a + * host with a value scores one. Ordering on it first keeps the absent hosts last + * in both directions, which the three supported databases do not agree on when a + * correlated subquery returns null. + */ + private function presence(Model $host): QueryBuilder + { + $values = $this->query($host); + + return $values + ->selectRaw('count(*)') + ->whereNotNull($this->column($values)) + ->toBase(); + } + + private function values(Model $host): QueryBuilder + { + $values = $this->query($host); + + return $values->select($this->column($values))->toBase(); + } + + /** @param Builder $values */ + private function column(Builder $values): string { /** @var CustomField $field */ $field = $this->field; - $model = $query->getModel(); - $column = $field->fieldType()->storageColumn(); + + return $values->getModel()->qualifyColumn($field->fieldType()->storageColumn()); + } + + /** + * Scopes of the configured value model are applied here, so a tenant scope keeps + * holding on the order as it does on the read. + * + * @return Builder + */ + private function query(Model $host): Builder + { + /** @var class-string $valueModel */ $valueModel = CustomFields::valueModel(); - $values = $valueModel::query() - ->select($valueModel::query()->getModel()->qualifyColumn($column)) - ->whereColumn( - $valueModel::query()->getModel()->qualifyColumn('valuable_id'), - $model->getQualifiedKeyName(), - ) - ->where($valueModel::query()->getModel()->qualifyColumn('valuable_type'), $model->getMorphClass()) - ->where($valueModel::query()->getModel()->qualifyColumn('custom_field_id'), $field->getKey()); - - $query->orderBy($values, $descending ? 'desc' : 'asc'); + $query = $valueModel::query(); + $values = $query->getModel(); + + return $query + ->whereColumn($values->qualifyColumn('valuable_id'), $host->getQualifiedKeyName()) + ->where($values->qualifyColumn('valuable_type'), $host->getMorphClass()) + ->where($values->qualifyColumn('custom_field_id'), $this->field->getKey()); } } diff --git a/src/Types/BooleanType.php b/src/Types/BooleanType.php index 9b818e6..ada1c1e 100644 --- a/src/Types/BooleanType.php +++ b/src/Types/BooleanType.php @@ -19,9 +19,18 @@ public function storageColumn(): string return 'value_boolean'; } + /** + * A request carries a boolean as text, so the textual forms are read as well. + * Validated writes only ever pass true, false, 1, 0, "1" and "0", which keep the + * same meaning they had before. + */ public function serialize(mixed $value, Model $field): mixed { - return $value === null ? null : (bool) $value; + if ($value === null) { + return null; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value; } public function deserialize(mixed $value, Model $field): mixed @@ -51,6 +60,6 @@ public function inputHint(): string public function queryOperations(): array { - return ['equals', 'sort']; + return ['equals', 'is_null', 'is_not_null', 'sort']; } } diff --git a/src/Types/DateType.php b/src/Types/DateType.php index b71e8f7..d465541 100644 --- a/src/Types/DateType.php +++ b/src/Types/DateType.php @@ -51,6 +51,6 @@ public function inputHint(): string public function queryOperations(): array { - return ['equals', 'greater_than', 'less_than', 'between', 'sort']; + return ['equals', 'greater_than', 'less_than', 'between', 'is_null', 'is_not_null', 'sort']; } } diff --git a/src/Types/MultiSelectType.php b/src/Types/MultiSelectType.php index 9557f32..734dbe8 100644 --- a/src/Types/MultiSelectType.php +++ b/src/Types/MultiSelectType.php @@ -51,6 +51,6 @@ public function inputHint(): string public function queryOperations(): array { - return ['contains_any', 'contains_all']; + return ['contains_any', 'contains_all', 'is_null', 'is_not_null']; } } diff --git a/src/Types/NumberType.php b/src/Types/NumberType.php index e5a509f..40d04a0 100644 --- a/src/Types/NumberType.php +++ b/src/Types/NumberType.php @@ -51,6 +51,6 @@ public function inputHint(): string public function queryOperations(): array { - return ['equals', 'in', 'greater_than', 'less_than', 'between', 'sort']; + return ['equals', 'in', 'greater_than', 'less_than', 'between', 'is_null', 'is_not_null', 'sort']; } } diff --git a/src/Types/SelectType.php b/src/Types/SelectType.php index 0749d44..56f4338 100644 --- a/src/Types/SelectType.php +++ b/src/Types/SelectType.php @@ -52,7 +52,7 @@ public function inputHint(): string public function queryOperations(): array { - return ['equals', 'in', 'sort']; + return ['equals', 'in', 'is_null', 'is_not_null', 'sort']; } /** @return array */ diff --git a/src/Types/TextType.php b/src/Types/TextType.php index 37a08b1..8f4aab4 100644 --- a/src/Types/TextType.php +++ b/src/Types/TextType.php @@ -51,6 +51,6 @@ public function inputHint(): string public function queryOperations(): array { - return ['equals', 'in', 'contains', 'sort']; + return ['equals', 'in', 'contains', 'is_null', 'is_not_null', 'sort']; } } diff --git a/src/Types/TextareaType.php b/src/Types/TextareaType.php index 2f8c344..3ff977d 100644 --- a/src/Types/TextareaType.php +++ b/src/Types/TextareaType.php @@ -35,6 +35,6 @@ public function inputHint(): string public function queryOperations(): array { - return ['equals', 'contains']; + return ['equals', 'contains', 'is_null', 'is_not_null']; } } diff --git a/src/Validation/ValueValidator.php b/src/Validation/ValueValidator.php index 19cea17..96c9187 100644 --- a/src/Validation/ValueValidator.php +++ b/src/Validation/ValueValidator.php @@ -8,76 +8,233 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Validator; use PlinCode\CustomFields\Facades\CustomFields; +use PlinCode\CustomFields\Models\CustomField; class ValueValidator { - /** @return array> */ - public function rules(Model|string $model, bool $complete = false): array + /** + * Every definition of the entity, active and inactive, keyed by slug. + * + * @return array + */ + public function definitions(Model|string $model): array { - $entityKey = CustomFields::entityKey($model); $fieldModel = CustomFields::fieldModel(); + + /** @var array $definitions */ + $definitions = $fieldModel::query() + ->where('entity_type', CustomFields::entityKey($model)) + ->get() + ->keyBy(static fn (Model $field): string => (string) $field->getAttribute('slug')) + ->all(); + + return $definitions; + } + + /** + * @param array|null $definitions + * @return array> + */ + public function rules(Model|string $model, bool $complete = false, ?array $definitions = null): array + { $rules = []; - foreach ($fieldModel::query()->where('entity_type', $entityKey)->where('is_active', true)->get() as $field) { + foreach ($definitions ?? $this->definitions($model) as $slug => $field) { + if (! $field->getAttribute('is_active')) { + continue; + } + + /** @var CustomField $field */ $fieldRules = $field->fieldType()->rules($field); - if ($complete && $field->is_required) { + if ($complete && $field->getAttribute('is_required')) { $fieldRules = array_values(array_diff($fieldRules, ['nullable'])); array_unshift($fieldRules, 'required'); } - $rules[$field->slug] = $fieldRules; + $rules[$slug] = $fieldRules; } return $rules; } - /** @param array $values */ - public function validate(Model $model, array $values, bool $complete = false): void + /** + * Partial validation only inspects the submitted keys. Complete validation + * inspects the state the model ends up with, so the values already stored + * satisfy the required definitions the caller left out of the payload. + * + * @param array $values + * @param array|null $definitions definitions keyed by slug, already loaded by the caller + * @param array|null $stored current values keyed by slug, already loaded by the caller + */ + public function validate(Model $model, array $values, bool $complete = false, ?array $definitions = null, ?array $stored = null): void { - $validator = Validator::make($values, $this->rules($model, $complete)); + $definitions ??= $this->definitions($model); + $stored ??= $this->storedValues($model, $definitions); - $validator->after(function (ValidatorContract $validator) use ($model, $values): void { - foreach ($values as $slug => $value) { - $field = $this->field($model, (string) $slug); + /** + * The type rules only ever run over the submitted values. A stored value + * was already validated when it was written, and a type is free to read + * back a shape it would not accept as input, so replaying the input + * rules over it would reject values the package itself produced. + */ + $rules = array_intersect_key($this->rules($model, false, $definitions), $values); + + $validator = Validator::make($values, $rules, [], $this->attributeNames($definitions)); + + $validator->after(function (ValidatorContract $validator) use ($values, $definitions, $stored, $complete): void { + foreach ($values as $key => $value) { + $slug = (string) $key; + $field = $definitions[$slug] ?? null; + + if ($field === null) { + $validator->errors()->add($slug, $this->message('unknown_field', ['attribute' => $slug])); - if ($field === null || ! in_array($field->getAttribute('type'), ['select', 'multiselect'], true)) { continue; } - $keys = array_map( - static fn (array $option): string => (string) ($option['key'] ?? ''), - (array) $field->getAttribute('options'), - ); - $activeKeys = array_map( - static fn (array $option): string => (string) ($option['key'] ?? ''), - array_filter((array) $field->getAttribute('options'), static fn (array $option): bool => (bool) ($option['is_active'] ?? true)), - ); - $valueModel = CustomFields::valueModel(); - $current = $valueModel::query() - ->where('custom_field_id', $field->getKey()) - ->where('valuable_type', $model->getMorphClass()) - ->where('valuable_id', $model->getKey()) - ->first()?->getValue(); - $submitted = $field->getAttribute('type') === 'multiselect' ? (array) $value : [$value]; - foreach ($submitted as $option) { - $isCurrent = is_array($current) ? in_array($option, $current, true) : $current === $option; - - if (! in_array($option, $keys, true) || (! in_array($option, $activeKeys, true) && ! $isCurrent)) { - $validator->errors()->add($slug, "The selected option [{$option}] is invalid."); - } + if (! $field->getAttribute('is_active')) { + $validator->errors()->add($slug, $this->message('inactive_field', [ + 'attribute' => (string) $field->getAttribute('name'), + ])); + + continue; } + + $this->validateOptions($validator, $field, $slug, $value, $stored[$slug] ?? null); + } + + if ($complete) { + $this->validateRequired($validator, $definitions, $values, $stored); } }); $validator->validate(); } - private function field(Model $model, string $slug): ?Model + /** + * A required definition is satisfied by the value the record ends up with, + * whether that value arrives in this payload or is already stored. Presence + * is decided here rather than through a required rule, so the stored value + * never has to pass the input rules of its own type a second time. + * + * @param array $definitions + * @param array $values + * @param array $stored + */ + private function validateRequired(ValidatorContract $validator, array $definitions, array $values, array $stored): void { - $fieldModel = CustomFields::fieldModel(); + foreach ($definitions as $slug => $field) { + if (! $field->getAttribute('is_active') || ! $field->getAttribute('is_required')) { + continue; + } + + $value = array_key_exists($slug, $values) ? $values[$slug] : ($stored[$slug] ?? null); + + if ($this->isFilled($value)) { + continue; + } + + $validator->errors()->add($slug, (string) trans('validation.required', [ + 'attribute' => (string) $field->getAttribute('name'), + ])); + } + } + + /** Mirrors how Laravel decides whether a value satisfies a required rule. */ + private function isFilled(mixed $value): bool + { + return match (true) { + $value === null => false, + is_string($value) => trim($value) !== '', + is_array($value) => $value !== [], + default => true, + }; + } + + /** + * An option can be kept by the record that already holds it, while a new + * assignment is limited to the options that are still active. + */ + private function validateOptions(ValidatorContract $validator, Model $field, string $slug, mixed $value, mixed $current): void + { + if ($value === null || ! in_array($field->getAttribute('type'), ['select', 'multiselect'], true)) { + return; + } + + /** @var CustomField $field */ + $keys = $field->optionKeys(); + $activeKeys = $field->activeOptionKeys(); + $submitted = $field->getAttribute('type') === 'multiselect' ? array_values((array) $value) : [$value]; + + foreach ($submitted as $option) { + $isCurrent = is_array($current) ? in_array($option, $current, true) : $current === $option; + + if (! in_array($option, $keys, true) || (! in_array($option, $activeKeys, true) && ! $isCurrent)) { + $validator->errors()->add($slug, $this->message('invalid_option', [ + 'option' => is_scalar($option) ? (string) $option : gettype($option), + 'attribute' => (string) $field->getAttribute('name'), + ])); + } + } + } + + /** + * The values already stored for the model, keyed by slug. + * + * @param array $definitions + * @return array + */ + private function storedValues(Model $model, array $definitions): array + { + if ($model->getKey() === null) { + return []; + } + + $slugs = []; + + foreach ($definitions as $slug => $field) { + $slugs[(string) $field->getKey()] = $slug; + } + + $valueModel = CustomFields::valueModel(); + $stored = []; - return $fieldModel::query()->where('entity_type', CustomFields::entityKey($model)) - ->where('slug', $slug)->where('is_active', true)->first(); + foreach ($valueModel::query() + ->where('valuable_type', $model->getMorphClass()) + ->where('valuable_id', $model->getKey()) + ->get() as $row) { + $slug = $slugs[(string) $row->getAttribute('custom_field_id')] ?? null; + + if ($slug === null) { + continue; + } + + $row->setRelation('customField', $definitions[$slug]); + $stored[$slug] = $row->getValue(); + } + + return $stored; + } + + /** + * @param array $definitions + * @return array + */ + private function attributeNames(array $definitions): array + { + $attributes = []; + + foreach ($definitions as $slug => $field) { + $attributes[$slug] = (string) $field->getAttribute('name'); + } + + return $attributes; + } + + /** @param array $replace */ + private function message(string $key, array $replace): string + { + return (string) trans('laravel-custom-fields::messages.validation.'.$key, $replace); } } diff --git a/tests/ArchTest.php b/tests/ArchTest.php index df644b7..1fe5572 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -13,3 +13,20 @@ arch('the package source declares strict types') ->expect('PlinCode\CustomFields') ->toUseStrictTypes(); + +arch('the package stays headless') + ->expect('PlinCode\CustomFields') + ->not->toUse([ + 'Illuminate\Http\Request', + 'Illuminate\Routing\Controller', + 'Illuminate\Support\Facades\Route', + 'Illuminate\Support\Facades\View', + ]); + +arch('every shipped type implements the field type contract') + ->expect('PlinCode\CustomFields\Types') + ->toImplement('PlinCode\CustomFields\Contracts\FieldType'); + +arch('the package exceptions are throwable') + ->expect('PlinCode\CustomFields\Exceptions') + ->toExtend('Exception'); diff --git a/tests/Feature/CustomFieldValueTest.php b/tests/Feature/CustomFieldValueTest.php new file mode 100644 index 0000000..0c74c99 --- /dev/null +++ b/tests/Feature/CustomFieldValueTest.php @@ -0,0 +1,252 @@ + $attributes */ +function field(array $attributes): CustomField +{ + return CustomField::create($attributes + ['entity_type' => 'article', 'type' => 'text']); +} + +it('writes and reads a typed custom value', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + + $article->setCustomField('rank', 10); + + expect($article->getCustomField('rank'))->toBe(10); +}); + +it('writes a batch of values atomically', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + field(['name' => 'Email', 'type' => 'email', 'is_required' => true]); + $article = Article::create(['title' => 'A']); + + $article->setCustomFields([ + 'rank' => 10, + 'email' => 'person@example.com', + ], complete: true); + + expect($article->getCustomFields())->toBe([ + 'rank' => 10, + 'email' => 'person@example.com', + ]); +}); + +it('writes nothing when one value of the batch is invalid', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + field(['name' => 'Email', 'type' => 'email']); + $article = Article::create(['title' => 'A']); + + expect(fn () => $article->setCustomFields(['rank' => 20, 'email' => 'invalid'])) + ->toThrow(ValidationException::class) + ->and($article->customFieldValues()->count())->toBe(0); +}); + +it('refuses a host that was never persisted before touching the database', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + $article = new Article(['title' => 'A']); + + DB::enableQueryLog(); + + expect(fn () => $article->setCustomField('rank', 5)) + ->toThrow(ModelNotPersistedException::class, 'Custom field values require a persisted ['.Article::class.'] instance.') + ->and(DB::getQueryLog())->toBe([]); +}); + +it('names the model in the error of an unknown slug', function (): void { + $article = Article::create(['title' => 'A']); + + expect(fn (): mixed => $article->getCustomField('rank')) + ->toThrow(UnknownCustomFieldException::class, 'Custom field [rank] is not defined for model ['.Article::class.'].') + ->and(fn (): mixed => $article->getCustomField('rank'))->toThrow(InvalidArgumentException::class); +}); + +it('reports an unknown slug of a batch as a validation error', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + + try { + $article->setCustomFields(['rank' => 1, 'ghost' => 'x']); + } catch (ValidationException $exception) { + expect($exception->errors())->toBe(['ghost' => ['The ghost field is not defined for this model.']]); + } + + expect($article->customFieldValues()->count())->toBe(0); +}); + +it('removes the stored row when a value is set to null', function (string $type, mixed $value, mixed $default): void { + field(['name' => 'Thing', 'type' => $type, 'options' => [ + ['key' => 'a', 'label' => 'A', 'is_active' => true], + ]]); + $article = Article::create(['title' => 'A']); + $article->setCustomField('thing', $value); + + expect($article->customFieldValues()->count())->toBe(1); + + $article->setCustomField('thing', null); + + expect($article->customFieldValues()->count())->toBe(0) + ->and($article->getCustomField('thing'))->toBe($default); +})->with([ + 'number' => ['number', 7, null], + 'select' => ['select', 'a', null], + 'multiselect' => ['multiselect', ['a'], []], +]); + +it('keeps the row of a multiselect cleared to an empty selection', function (): void { + field(['name' => 'Tags', 'type' => 'multiselect', 'options' => [ + ['key' => 'a', 'label' => 'A', 'is_active' => true], + ]]); + $article = Article::create(['title' => 'A']); + $article->setCustomField('tags', ['a']); + + $article->setCustomField('tags', []); + + expect($article->customFieldValues()->count())->toBe(1) + ->and($article->getCustomField('tags'))->toBe([]); +}); + +it('dispatches the value events on a write, a clear and a null', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + field(['name' => 'Notes', 'type' => 'text']); + $article = Article::create(['title' => 'A']); + + Event::fake([CustomFieldValueSaved::class, CustomFieldValueDeleted::class]); + + $article->setCustomFields(['rank' => 1, 'notes' => 'x']); + $article->clearCustomField('rank'); + $article->setCustomField('notes', null); + + Event::assertDispatchedTimes(CustomFieldValueSaved::class, 2); + Event::assertDispatchedTimes(CustomFieldValueDeleted::class, 2); +}); + +it('reads a deactivated definition only when the caller asks for it', function (): void { + $field = field(['name' => 'Legacy', 'type' => 'text']); + $article = Article::create(['title' => 'A']); + $article->setCustomField('legacy', 'kept'); + + $field->update(['is_active' => false]); + + expect($article->getCustomField('legacy', includeInactive: true))->toBe('kept') + ->and($article->getCustomFields())->toBe([]) + ->and($article->getCustomFields(includeInactive: true))->toBe(['legacy' => 'kept']) + ->and(fn (): mixed => $article->getCustomField('legacy')) + ->toThrow(UnknownCustomFieldException::class, 'Custom field [legacy] is not active for model ['.Article::class.'].'); +}); + +it('clears the value of a deactivated definition without a flag', function (): void { + $field = field(['name' => 'Legacy', 'type' => 'text']); + $article = Article::create(['title' => 'A']); + $article->setCustomField('legacy', 'kept'); + $field->update(['is_active' => false]); + + $article->clearCustomField('legacy'); + + expect($article->customFieldValues()->count())->toBe(0); +}); + +it('refuses to write to a deactivated definition', function (): void { + $field = field(['name' => 'Legacy', 'type' => 'text']); + $article = Article::create(['title' => 'A']); + $field->update(['is_active' => false]); + + try { + $article->setCustomField('legacy', 'new'); + } catch (ValidationException $exception) { + expect($exception->errors())->toBe(['legacy' => ['The legacy field is not active.']]); + } + + expect($article->customFieldValues()->count())->toBe(0); +}); + +it('serves a fresh relation after a write on the same instance', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + $article->load('customFieldValues'); + + $article->setCustomField('rank', 3); + + expect($article->customFieldValues)->toHaveCount(1) + ->and($article->getCustomField('rank'))->toBe(3); +}); + +it('filters hosts on a stored value through the model scope', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + $first = Article::create(['title' => 'First']); + $second = Article::create(['title' => 'Second']); + $first->setCustomField('rank', 10); + $second->setCustomField('rank', 2); + + expect(Article::whereCustomField('rank', 10)->pluck('title')->all())->toBe(['First']); +}); + +it('writes a batch of three slugs in a constant number of queries', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + field(['name' => 'Email', 'type' => 'email']); + field(['name' => 'Notes', 'type' => 'text']); + $article = Article::create(['title' => 'A']); + + DB::enableQueryLog(); + $article->setCustomFields(['rank' => 1, 'email' => 'a@example.com', 'notes' => 'x']); + $insert = count(DB::getQueryLog()); + + DB::flushQueryLog(); + $article->setCustomFields(['rank' => 2, 'email' => 'b@example.com', 'notes' => 'y']); + $update = count(DB::getQueryLog()); + + // One query for the definitions, one for the stored rows, one per written slug. + expect($insert)->toBe(5) + ->and($update)->toBe(5); +}); + +it('reads every value of a host in two queries', function (): void { + field(['name' => 'Rank', 'type' => 'number']); + field(['name' => 'Notes', 'type' => 'text']); + $article = Article::create(['title' => 'A']); + $article->setCustomFields(['rank' => 1, 'notes' => 'x']); + + DB::enableQueryLog(); + $article->getCustomFields(); + $all = count(DB::getQueryLog()); + + DB::flushQueryLog(); + $article->getCustomField('rank'); + $one = count(DB::getQueryLog()); + + expect($all)->toBe(2)->and($one)->toBe(2); +}); + +it('ignores a value row that belongs to another host', function (): void { + $field = field(['name' => 'Rank', 'type' => 'number']); + $first = Article::create(['title' => 'First']); + $second = Article::create(['title' => 'Second']); + $first->setCustomField('rank', 10); + + CustomFieldValue::create([ + 'custom_field_id' => $field->getKey(), + 'valuable_type' => 'article', + 'valuable_id' => $second->getKey(), + 'value_integer' => 2, + ]); + + expect($first->getCustomField('rank'))->toBe(10) + ->and($second->getCustomField('rank'))->toBe(2); +}); diff --git a/tests/Feature/CustomFieldsTest.php b/tests/Feature/CustomFieldsTest.php index 99e6c17..00d1010 100644 --- a/tests/Feature/CustomFieldsTest.php +++ b/tests/Feature/CustomFieldsTest.php @@ -6,12 +6,12 @@ use Illuminate\Validation\ValidationException; use PlinCode\CustomFields\Facades\CustomFields; use PlinCode\CustomFields\Models\CustomField; -use PlinCode\CustomFields\Query\CustomFieldFilter; -use PlinCode\CustomFields\Query\CustomFieldSorter; use Workbench\App\Models\Article; +use Workbench\App\Models\Project; beforeEach(function (): void { CustomFields::registerEntity(Article::class, 'article'); + CustomFields::registerEntity(Project::class, 'project'); }); it('creates a normalized field with a stable slug', function (): void { @@ -25,77 +25,64 @@ ->and($field->slug)->toBe('sector'); }); -it('registers the entity in the Laravel morph map', function (): void { - expect(Relation::getMorphedModel('article'))->toBe(Article::class); +it('rejects a field without a name', function (): void { + expect(fn (): CustomField => CustomField::create([ + 'entity_type' => 'article', + 'name' => ' ', + 'type' => 'text', + ]))->toThrow(InvalidArgumentException::class, 'A custom field name cannot be empty.'); }); -it('uses configured string keys for field models', function (): void { - config()->set('laravel-custom-fields.key_type', 'ulid'); - - $field = new CustomField; - - expect($field->getKeyType())->toBe('string') - ->and($field->getIncrementing())->toBeFalse(); +it('registers the entity in the Laravel morph map', function (): void { + expect(Relation::getMorphedModel('article'))->toBe(Article::class) + ->and(Relation::getMorphedModel('project'))->toBe(Project::class); }); -it('writes and reads a typed custom value', function (): void { - $field = CustomField::create([ - 'entity_type' => 'article', - 'name' => 'Rank', - 'type' => 'number', - ]); - $article = Article::create(['title' => 'A']); +it('refuses to register two models under the same entity key', function (): void { + expect(fn () => CustomFields::registerEntity(Article::class.'Other', 'article')) + ->toThrow(InvalidArgumentException::class, 'Custom field entity key [article] is already registered.'); +}); - $article->setCustomField($field->slug, 10); +it('gives two names that slugify alike two distinct slugs', function (): void { + $first = CustomField::create(['entity_type' => 'article', 'name' => 'A B', 'type' => 'text']); + $second = CustomField::create(['entity_type' => 'article', 'name' => 'A-B', 'type' => 'text']); - expect($article->getCustomField($field->slug))->toBe(10); + expect($first->slug)->toBe('a-b') + ->and($second->slug)->toBe('a-b-2'); }); -it('keeps fields isolated by entity type', function (): void { - CustomFields::registerEntity(Article::class, 'another-article'); +it('keeps a generated slug within one hundred characters after a collision', function (): void { + $name = str_repeat('a', 120); + $first = CustomField::create(['entity_type' => 'article', 'name' => $name, 'type' => 'text']); + $second = CustomField::create(['entity_type' => 'article', 'name' => $name.' b', 'type' => 'text']); - $field = CustomField::create([ - 'entity_type' => 'missing', - 'name' => 'Unknown', - 'type' => 'text', - ]); - - expect($field->entity_type)->toBe('missing'); + expect(mb_strlen($first->slug))->toBe(100) + ->and(mb_strlen($second->slug))->toBe(100) + ->and($second->slug)->toEndWith('a-2') + ->and($second->slug)->not->toBe($first->slug); }); -it('validates a custom value before writing it', function (): void { - $field = CustomField::create([ - 'entity_type' => 'article', - 'name' => 'Rank', - 'type' => 'number', - ]); +it('keeps fields isolated between two host models', function (): void { + CustomField::create(['entity_type' => 'article', 'name' => 'Rank', 'type' => 'number']); + CustomField::create(['entity_type' => 'project', 'name' => 'Rank', 'type' => 'text']); + $article = Article::create(['title' => 'A']); + $project = Project::create(['title' => 'P']); + $article->setCustomField('rank', 5); + $project->setCustomField('rank', 'five'); - expect(fn () => $article->setCustomField($field->slug, 'invalid')) - ->toThrow(ValidationException::class); + expect($article->getCustomFields())->toBe(['rank' => 5]) + ->and($project->getCustomFields())->toBe(['rank' => 'five']) + ->and(fn () => $article->setCustomField('rank', 'five'))->toThrow(ValidationException::class); }); -it('keeps an inactive selected option readable but blocks new assignments', function (): void { - $field = CustomField::create([ - 'entity_type' => 'article', - 'name' => 'Status', - 'type' => 'select', - 'options' => [ - ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => true], - ['key' => 'current', 'label' => 'Current', 'is_active' => true], - ], - ]); +it('hides the definitions of one host from the other', function (): void { + CustomField::create(['entity_type' => 'project', 'name' => 'Budget', 'type' => 'number']); $article = Article::create(['title' => 'A']); - $article->setCustomField($field->slug, 'legacy'); - - $field->update(['options' => [ - ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => false], - ['key' => 'current', 'label' => 'Current', 'is_active' => true], - ]]); - expect($article->getCustomField($field->slug))->toBe('legacy') - ->and(fn () => Article::create(['title' => 'B'])->setCustomField($field->slug, 'legacy')) - ->toThrow(ValidationException::class); + expect($article->getCustomFields())->toBe([]) + ->and(fn () => $article->getCustomField('budget')) + ->toThrow(InvalidArgumentException::class, 'Custom field [budget] is not defined for model ['.Article::class.'].'); }); it('keeps option keys stable while allowing labels to change', function (): void { @@ -116,84 +103,78 @@ expect($field->refresh()->options[0]['label'])->toBe('Vendita') ->and(fn () => $field->updateOptions([ ['key' => 'public', 'label' => 'Public', 'is_active' => true], - ]))->toThrow(InvalidArgumentException::class); + ]))->toThrow(InvalidArgumentException::class, 'Custom field option [retail] cannot be removed.'); }); -it('protects the stable slug from ordinary updates', function (): void { +it('rejects a duplicated option key', function (): void { + expect(fn (): CustomField => CustomField::create([ + 'entity_type' => 'article', + 'name' => 'Sector', + 'type' => 'select', + 'options' => [ + ['key' => 'retail', 'label' => 'Retail', 'is_active' => true], + ['key' => 'retail', 'label' => 'Retail again', 'is_active' => true], + ], + ]))->toThrow(InvalidArgumentException::class, 'Custom field option [retail] is duplicated.'); +}); + +it('rejects an option without a key or a label', function (): void { + expect(fn (): CustomField => CustomField::create([ + 'entity_type' => 'article', + 'name' => 'Sector', + 'type' => 'select', + 'options' => [['key' => 'retail', 'label' => '']], + ]))->toThrow(InvalidArgumentException::class, 'Custom field options require a key and label.'); +}); + +it('separates every option key from the keys still assignable', function (): void { $field = CustomField::create([ 'entity_type' => 'article', 'name' => 'Sector', - 'type' => 'text', + 'type' => 'select', + 'options' => [ + ['key' => 'retail', 'label' => 'Retail', 'is_active' => false], + ['key' => 'public', 'label' => 'Public', 'is_active' => true], + ], ]); - expect(fn () => $field->update(['slug' => 'changed'])) - ->toThrow(InvalidArgumentException::class); + expect($field->optionKeys())->toBe(['retail', 'public']) + ->and($field->activeOptionKeys())->toBe(['public']) + ->and($field->optionsForInput())->toHaveCount(1); }); -it('protects the field type when values already exist', function (): void { +it('protects the stable slug from ordinary updates', function (): void { $field = CustomField::create([ 'entity_type' => 'article', - 'name' => 'Rank', - 'type' => 'number', + 'name' => 'Sector', + 'type' => 'text', ]); - $article = Article::create(['title' => 'A']); - $article->setCustomField($field->slug, 10); - expect(fn () => $field->update(['type' => 'text'])) - ->toThrow(InvalidArgumentException::class); + expect(fn (): bool => $field->update(['slug' => 'changed'])) + ->toThrow(InvalidArgumentException::class, 'A custom field slug and entity cannot be changed.'); }); -it('filters and sorts entities through the custom field query adapters', function (): void { +it('keeps the slug when the name changes', function (): void { $field = CustomField::create([ 'entity_type' => 'article', - 'name' => 'Rank', - 'type' => 'number', + 'name' => 'Sector', + 'type' => 'text', ]); - $first = Article::create(['title' => 'First']); - $second = Article::create(['title' => 'Second']); - $first->setCustomField($field->slug, 10); - $second->setCustomField($field->slug, 2); - - $filtered = Article::query(); - (new CustomFieldFilter($field))($filtered, 10, 'cf_rank'); - $sorted = Article::query(); - (new CustomFieldSorter($field))($sorted, false, 'cf_rank'); + $field->update(['name' => 'Market Segment']); - expect($filtered->pluck('title')->all())->toBe(['First']) - ->and($sorted->pluck('title')->all())->toBe(['Second', 'First']); + expect($field->refresh()->slug)->toBe('sector') + ->and($field->name)->toBe('market segment'); }); -it('writes a batch of values atomically and supports complete validation', function (): void { - $rank = CustomField::create([ +it('protects the field type when values already exist', function (): void { + $field = CustomField::create([ 'entity_type' => 'article', 'name' => 'Rank', 'type' => 'number', ]); - $email = CustomField::create([ - 'entity_type' => 'article', - 'name' => 'Email', - 'type' => 'email', - 'is_required' => true, - ]); - $article = Article::create(['title' => 'A']); - - $article->setCustomFields([ - $rank->slug => 10, - $email->slug => 'person@example.com', - ], complete: true); - - expect($article->getCustomFields())->toMatchArray([ - 'rank' => 10, - 'email' => 'person@example.com', - ]); - - $other = Article::create(['title' => 'B']); - - expect(fn () => $other->setCustomFields([ - $rank->slug => 20, - $email->slug => 'invalid', - ]))->toThrow(ValidationException::class); + Article::create(['title' => 'A'])->setCustomField($field->slug, 10); - expect($other->getCustomField($rank->slug))->toBeNull(); + expect(fn (): bool => $field->update(['type' => 'text'])) + ->toThrow(InvalidArgumentException::class, 'A custom field type cannot change while values exist.'); }); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index cfc298a..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,22 +0,0 @@ -toBeInstanceOf(CustomFields::class); -}); - -it('returns the same instance from the container', function () { - expect(app(CustomFields::class))->toBe(app(CustomFields::class)); -}); - -it('merges the package config', function () { - expect(config('laravel-custom-fields.key_type'))->toBe('id') - ->and(config('laravel-custom-fields.morph_key_type'))->toBe('uuid'); -}); - -it('loads the package translations', function () { - expect(trans('laravel-custom-fields::messages.validation.invalid_option'))->toBe('The selected option is invalid.'); -}); diff --git a/tests/Feature/KeyTypeTest.php b/tests/Feature/KeyTypeTest.php new file mode 100644 index 0000000..142a4f4 --- /dev/null +++ b/tests/Feature/KeyTypeTest.php @@ -0,0 +1,85 @@ +firstWhere('name', $column); + + return is_array($found) ? (string) $found['type_name'] : ''; +} + +it('keeps integer keys on both tables by default', function (): void { + CustomFields::registerEntity(Article::class, 'article'); + CustomField::create(['entity_type' => 'article', 'name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + $article->setCustomField('rank', 5); + + expect(columnType('custom_fields', 'id'))->toBe('integer') + ->and(columnType('custom_field_values', 'valuable_id'))->toBe('integer') + ->and($article->customFieldValues()->first()?->getKey())->toBeInt() + ->and((new CustomField)->getKeyType())->toBe('int') + ->and((new CustomField)->getIncrementing())->toBeTrue(); +}); + +it('stores the values of a uuid keyed host', function (): void { + config()->set('laravel-custom-fields.key_type', 'uuid'); + config()->set('laravel-custom-fields.morph_key_type', 'uuid'); + $this->rebuildPackageTables(); + + Schema::create('uuid_documents', function (Blueprint $table): void { + $table->uuid('id')->primary(); + $table->string('title'); + $table->timestamps(); + }); + + CustomFields::registerEntity(UuidDocument::class, 'uuid-document'); + $field = CustomField::create(['entity_type' => 'uuid-document', 'name' => 'Rank', 'type' => 'number']); + $document = UuidDocument::create(['title' => 'A']); + $document->setCustomField('rank', 7); + $row = $document->customFieldValues()->first(); + + expect(columnType('custom_fields', 'id'))->toBe('varchar') + ->and(columnType('custom_field_values', 'valuable_id'))->toBe('varchar') + ->and(Str::isUuid((string) $field->getKey()))->toBeTrue() + ->and(Str::isUuid((string) $row?->getKey()))->toBeTrue() + ->and($row?->getAttribute('valuable_id'))->toBe($document->getKey()) + ->and($document->getCustomField('rank'))->toBe(7); +}); + +it('stores the values of a ulid keyed host', function (): void { + config()->set('laravel-custom-fields.key_type', 'ulid'); + config()->set('laravel-custom-fields.morph_key_type', 'ulid'); + $this->rebuildPackageTables(); + + Schema::create('ulid_documents', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->string('title'); + $table->timestamps(); + }); + + CustomFields::registerEntity(UlidDocument::class, 'ulid-document'); + $field = CustomField::create(['entity_type' => 'ulid-document', 'name' => 'Rank', 'type' => 'number']); + $document = UlidDocument::create(['title' => 'A']); + $document->setCustomField('rank', 7); + $row = $document->customFieldValues()->first(); + + expect(columnType('custom_fields', 'id'))->toBe('varchar') + ->and(columnType('custom_field_values', 'valuable_id'))->toBe('varchar') + ->and(Str::isUlid((string) $field->getKey()))->toBeTrue() + ->and(Str::isUlid((string) $row?->getKey()))->toBeTrue() + ->and($row?->getAttribute('valuable_id'))->toBe($document->getKey()) + ->and($document->getCustomField('rank'))->toBe(7) + ->and((new CustomField)->getKeyType())->toBe('string') + ->and((new CustomField)->getIncrementing())->toBeFalse(); +}); diff --git a/tests/Feature/QueryTest.php b/tests/Feature/QueryTest.php new file mode 100644 index 0000000..7b8cb55 --- /dev/null +++ b/tests/Feature/QueryTest.php @@ -0,0 +1,305 @@ + $attributes */ +function queryable(array $attributes): CustomField +{ + return CustomField::create($attributes + ['entity_type' => 'article', 'type' => 'text']); +} + +/** @return array */ +function titlesFor(string $url): array +{ + return QueryBuilder::for(Article::class, Request::create($url)) + ->allowedFilters(...CustomFields::filtersFor(Article::class)) + ->allowedSorts(...CustomFields::sortsFor(Article::class)) + ->pluck('title') + ->all(); +} + +/** @return array */ +function filterNames(): array +{ + return array_map( + static fn (object $filter): string => (string) $filter->getName(), + CustomFields::filtersFor(Article::class), + ); +} + +/** @return array */ +function sortNames(): array +{ + return array_map( + static fn (object $sort): string => (string) $sort->getName(), + CustomFields::sortsFor(Article::class), + ); +} + +/** @param array $values */ +function articleWith(string $title, array $values): Article +{ + $article = Article::create(['title' => $title]); + $article->setCustomFields($values); + + return $article; +} + +it('exposes one filter per declared operation of a field', function (): void { + queryable(['name' => 'Rank', 'type' => 'number']); + + expect(filterNames())->toBe([ + 'cf_rank', + 'cf_rank:in', + 'cf_rank:greater_than', + 'cf_rank:less_than', + 'cf_rank:between', + 'cf_rank:is_null', + 'cf_rank:is_not_null', + ])->and(sortNames())->toBe(['cf_rank']); +}); + +it('never exposes an equality filter on a multiselect', function (): void { + queryable(['name' => 'Tags', 'type' => 'multiselect', 'options' => [ + ['key' => 'a', 'label' => 'A', 'is_active' => true], + ['key' => 'b', 'label' => 'B', 'is_active' => true], + ]]); + articleWith('One', ['tags' => ['a']]); + + expect(filterNames())->toBe([ + 'cf_tags:contains_any', + 'cf_tags:contains_all', + 'cf_tags:is_null', + 'cf_tags:is_not_null', + ]) + ->and(sortNames())->toBe([]) + ->and(fn (): array => titlesFor('/?filter[cf_tags]=a'))->toThrow(InvalidFilterQuery::class); +}); + +it('matches a multiselect on the keys it holds', function (): void { + queryable(['name' => 'Tags', 'type' => 'multiselect', 'options' => [ + ['key' => 'a', 'label' => 'A', 'is_active' => true], + ['key' => 'b', 'label' => 'B', 'is_active' => true], + ]]); + articleWith('One', ['tags' => ['a']]); + articleWith('Two', ['tags' => ['a', 'b']]); + articleWith('Three', ['tags' => []]); + + expect(titlesFor('/?filter[cf_tags:contains_any]=a'))->toBe(['One', 'Two']) + ->and(titlesFor('/?filter[cf_tags:contains_any]=a,b'))->toBe(['One', 'Two']) + ->and(titlesFor('/?filter[cf_tags:contains_all]=a,b'))->toBe(['Two']) + ->and(titlesFor('/?filter[cf_tags:is_not_null]=1'))->toBe(['One', 'Two', 'Three']) + ->and(titlesFor('/?filter[cf_tags:is_null]=1'))->toBe([]); +}); + +it('refuses an operation the field type does not declare', function (): void { + $tags = queryable(['name' => 'Tags', 'type' => 'multiselect', 'options' => [ + ['key' => 'a', 'label' => 'A', 'is_active' => true], + ]]); + $notes = queryable(['name' => 'Notes', 'type' => 'textarea']); + + expect(fn (): CustomFieldFilter => new CustomFieldFilter($tags, CustomFieldFilter::EQUALS)) + ->toThrow(InvalidArgumentException::class, 'Custom field [tags] does not declare the query operation [equals].') + ->and(fn (): CustomFieldSorter => new CustomFieldSorter($tags)) + ->toThrow(InvalidArgumentException::class, 'Custom field [tags] does not declare the query operation [sort].') + ->and(fn (): CustomFieldSorter => new CustomFieldSorter($notes)) + ->toThrow(InvalidArgumentException::class, 'Custom field [notes] does not declare the query operation [sort].'); +}); + +it('refuses an operation the filter cannot serve', function (): void { + $rank = queryable(['name' => 'Rank', 'type' => 'number']); + + expect(fn (): CustomFieldFilter => new CustomFieldFilter($rank, 'regex')) + ->toThrow(InvalidArgumentException::class, 'Custom field query operation [regex] is not supported.') + ->and(CustomFieldFilter::supports('regex'))->toBeFalse() + ->and(CustomFieldFilter::supports(CustomFieldFilter::BETWEEN))->toBeTrue() + ->and((new CustomFieldFilter($rank))->operation())->toBe(CustomFieldFilter::EQUALS); +}); + +it('compares and ranges a number field', function (): void { + queryable(['name' => 'Rank', 'type' => 'number']); + articleWith('a', ['rank' => 1]); + articleWith('b', ['rank' => 5]); + articleWith('c', ['rank' => 9]); + + expect(titlesFor('/?filter[cf_rank]=5'))->toBe(['b']) + ->and(titlesFor('/?filter[cf_rank:in]=1,9'))->toBe(['a', 'c']) + ->and(titlesFor('/?filter[cf_rank:greater_than]=4'))->toBe(['b', 'c']) + ->and(titlesFor('/?filter[cf_rank:less_than]=5'))->toBe(['a']) + ->and(titlesFor('/?filter[cf_rank:between]=2,9'))->toBe(['b', 'c']); +}); + +it('compares and ranges a date field', function (): void { + queryable(['name' => 'Due', 'type' => 'date']); + articleWith('a', ['due' => '2024-01-01']); + articleWith('b', ['due' => '2024-06-01']); + articleWith('c', ['due' => '2024-12-01']); + + expect(titlesFor('/?filter[cf_due]=2024-06-01'))->toBe(['b']) + ->and(titlesFor('/?filter[cf_due:greater_than]=2024-05-01'))->toBe(['b', 'c']) + ->and(titlesFor('/?filter[cf_due:less_than]=2024-05-01'))->toBe(['a']) + ->and(titlesFor('/?filter[cf_due:between]=2024-04-01,2024-12-01'))->toBe(['b', 'c']); +}); + +it('rejects a range that does not carry two bounds', function (): void { + queryable(['name' => 'Rank', 'type' => 'number']); + + expect(fn (): array => titlesFor('/?filter[cf_rank:between]=2'))->toThrow(InvalidFilterValue::class) + ->and(fn (): array => titlesFor('/?filter[cf_rank:greater_than]=2,3'))->toThrow(InvalidFilterValue::class); +}); + +it('reads a boolean filter written as text', function (): void { + queryable(['name' => 'Flag', 'type' => 'boolean']); + articleWith('on', ['flag' => true]); + articleWith('off', ['flag' => false]); + + expect(titlesFor('/?filter[cf_flag]=true'))->toBe(['on']) + ->and(titlesFor('/?filter[cf_flag]=1'))->toBe(['on']) + ->and(titlesFor('/?filter[cf_flag]=false'))->toBe(['off']) + ->and(titlesFor('/?filter[cf_flag]=0'))->toBe(['off']); +}); + +it('treats a missing row and a null column alike for presence', function (): void { + $rank = queryable(['name' => 'Rank', 'type' => 'number']); + articleWith('answered', ['rank' => 1]); + $blank = Article::create(['title' => 'blank']); + Article::create(['title' => 'missing']); + + CustomFieldValue::create([ + 'custom_field_id' => $rank->getKey(), + 'valuable_type' => 'article', + 'valuable_id' => $blank->getKey(), + ]); + + expect(titlesFor('/?filter[cf_rank:is_null]=1'))->toBe(['blank', 'missing']) + ->and(titlesFor('/?filter[cf_rank:is_not_null]=1'))->toBe(['answered']) + ->and(titlesFor('/?filter[cf_rank:is_null]=0'))->toBe(['answered']); +}); + +it('matches a literal wildcard through the contains operation', function (): void { + queryable(['name' => 'Notes', 'type' => 'text']); + articleWith('percent', ['notes' => '50% off']); + articleWith('underscore', ['notes' => 'off_x']); + articleWith('plain', ['notes' => 'offax']); + articleWith('backslash', ['notes' => 'back\\slash']); + articleWith('bang', ['notes' => 'bang!']); + + expect(titlesFor('/?filter[cf_notes:contains]=50%'))->toBe(['percent']) + ->and(titlesFor('/?filter[cf_notes:contains]=off_x'))->toBe(['underscore']) + ->and(titlesFor('/?filter[cf_notes:contains]=%%'))->toBe([]) + ->and(titlesFor('/?filter[cf_notes:contains]='.urlencode('back\\slash')))->toBe(['backslash']) + ->and(titlesFor('/?filter[cf_notes:contains]='.urlencode('bang!')))->toBe(['bang']); +}); + +it('groups the values of a contains filter as an or', function (): void { + queryable(['name' => 'Notes', 'type' => 'text']); + articleWith('first', ['notes' => 'alpha one']); + articleWith('second', ['notes' => 'beta two']); + articleWith('third', ['notes' => 'gamma three']); + + expect(titlesFor('/?filter[cf_notes:contains]=alpha,beta'))->toBe(['first', 'second']); +}); + +it('places the hosts without a value last in both directions', function (): void { + queryable(['name' => 'Rank', 'type' => 'number']); + articleWith('a', ['rank' => 3]); + articleWith('b', ['rank' => 1]); + articleWith('c', ['rank' => 2]); + Article::create(['title' => 'none']); + + expect(titlesFor('/?sort=cf_rank'))->toBe(['b', 'c', 'a', 'none']) + ->and(titlesFor('/?sort=-cf_rank'))->toBe(['a', 'c', 'b', 'none']); +}); + +it('leaves the select, the grouping and the order of the caller alone', function (): void { + $rank = queryable(['name' => 'Rank', 'type' => 'number']); + articleWith('a', ['rank' => 3]); + articleWith('b', ['rank' => 1]); + Article::create(['title' => 'none']); + + $query = Article::query() + ->select('articles.id', 'articles.title') + ->selectRaw('count(*) as value_count') + ->groupBy('articles.id', 'articles.title') + ->orderBy('articles.title'); + + (new CustomFieldSorter($rank))($query, false, 'cf_rank'); + + expect($query->get()->pluck('value_count')->all())->toBe([1, 1, 1]) + ->and($query->get()->pluck('title')->all())->toBe(['a', 'b', 'none']); +}); + +it('composes a custom field sort with a sorter of the sibling package', function (): void { + queryable(['name' => 'Rank', 'type' => 'number']); + $zoe = Author::create(['name' => 'zoe']); + $ada = Author::create(['name' => 'ada']); + Article::create(['title' => 'one', 'author_id' => $zoe->id])->setCustomField('rank', 1); + Article::create(['title' => 'two', 'author_id' => $ada->id])->setCustomField('rank', 9); + Article::create(['title' => 'three', 'author_id' => $ada->id])->setCustomField('rank', 2); + + $titles = QueryBuilder::for(Article::class, Request::create('/?sort=author,cf_rank')) + ->allowedSorts( + AllowedSort::custom('author', new RelationSorter('authors', 'author_id', 'name')), + ...CustomFields::sortsFor(Article::class), + ) + ->pluck('title') + ->all(); + + expect($titles)->toBe(['three', 'two', 'one']); +}); + +it('follows the configured key prefix', function (): void { + config()->set('laravel-custom-fields.key_prefix', 'x-'); + queryable(['name' => 'Rank', 'type' => 'number']); + + expect(CustomFields::keyPrefix())->toBe('x-') + ->and(CustomFields::filterName('rank'))->toBe('x-rank') + ->and(CustomFields::filterName('rank', CustomFieldFilter::BETWEEN))->toBe('x-rank:between') + ->and(CustomFields::sortName('rank'))->toBe('x-rank') + ->and(filterNames())->toContain('x-rank', 'x-rank:between') + ->and(sortNames())->toBe(['x-rank']); +}); + +it('reads the definitions once when filters and sorts are asked together', function (): void { + queryable(['name' => 'Rank', 'type' => 'number']); + + DB::enableQueryLog(); + $options = CustomFields::queryOptionsFor(Article::class); + $together = count(DB::getQueryLog()); + + DB::flushQueryLog(); + CustomFields::filtersFor(Article::class); + CustomFields::sortsFor(Article::class); + $apart = count(DB::getQueryLog()); + + expect($together)->toBe(1) + ->and($apart)->toBe(2) + ->and($options['filters'])->toHaveCount(7) + ->and($options['sorts'])->toHaveCount(1); +}); + +it('offers neither a filter nor a sort for a deactivated field', function (): void { + queryable(['name' => 'Rank', 'type' => 'number', 'is_active' => false]); + + expect(filterNames())->toBe([]) + ->and(sortNames())->toBe([]); +}); diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php new file mode 100644 index 0000000..8db84b1 --- /dev/null +++ b/tests/Feature/ServiceProviderTest.php @@ -0,0 +1,183 @@ + */ +function publishedPaths(string $tag): array +{ + $root = normalisePath(dirname(__DIR__, 2)).'/'; + + return array_map( + static fn (string $path): string => str_replace([$root, 'src/../'], '', normalisePath($path)), + array_keys(ServiceProvider::pathsToPublish(CustomFieldsServiceProvider::class, $tag)), + ); +} + +/** @return array */ +function publishedTargets(string $tag): array +{ + return array_map( + 'normalisePath', + array_values(ServiceProvider::pathsToPublish(CustomFieldsServiceProvider::class, $tag)), + ); +} + +it('resolves the singleton', function (): void { + expect(app(CustomFields::class))->toBeInstanceOf(CustomFields::class); +}); + +it('returns the same instance from the container', function (): void { + expect(app(CustomFields::class))->toBe(app(CustomFields::class)); +}); + +it('merges the package config', function (): void { + expect(config('laravel-custom-fields.key_type'))->toBe('id') + ->and(config('laravel-custom-fields.morph_key_type'))->toBe('id') + ->and(config('laravel-custom-fields.key_prefix'))->toBe('cf_') + ->and(config('laravel-custom-fields.tables.fields'))->toBe('custom_fields') + ->and(config('laravel-custom-fields.tables.values'))->toBe('custom_field_values'); +}); + +it('registers the publish tags of the package', function (): void { + expect(array_values(array_filter( + array_keys(ServiceProvider::$publishGroups), + static fn (string $tag): bool => str_starts_with($tag, 'laravel-custom-fields'), + )))->toEqualCanonicalizing([ + 'laravel-custom-fields', + 'laravel-custom-fields-config', + 'laravel-custom-fields-lang', + 'laravel-custom-fields-migrations', + ]); +}); + +it('publishes only the config file under the config tag', function (): void { + expect(publishedPaths('laravel-custom-fields-config')) + ->toBe(['config/laravel-custom-fields.php']); +}); + +it('publishes only the translations under the lang tag', function (): void { + expect(publishedPaths('laravel-custom-fields-lang'))->toBe(['lang']) + ->and(publishedTargets('laravel-custom-fields-lang')) + ->each->toEndWith('lang/vendor/laravel-custom-fields'); +}); + +it('publishes both migrations under the migrations tag', function (): void { + expect(publishedPaths('laravel-custom-fields-migrations'))->toBe([ + 'database/migrations/2026_01_01_000000_create_custom_fields_table.php', + 'database/migrations/2026_01_01_000001_create_custom_field_values_table.php', + ]); +}); + +it('publishes config, translations and migrations under the umbrella tag', function (): void { + expect(publishedPaths('laravel-custom-fields'))->toEqualCanonicalizing([ + 'config/laravel-custom-fields.php', + 'lang', + 'database/migrations/2026_01_01_000000_create_custom_fields_table.php', + 'database/migrations/2026_01_01_000001_create_custom_field_values_table.php', + ]); +}); + +it('ships the migrations under the name of the tables they create', function (): void { + $files = array_map('basename', (array) glob(__DIR__.'/../../database/migrations/*.php')); + + expect($files)->toBe([ + '2026_01_01_000000_create_custom_fields_table.php', + '2026_01_01_000001_create_custom_field_values_table.php', + ]); +}); + +it('registers the twelve built in field types', function (): void { + expect(array_keys(CustomFieldsFacade::types()))->toEqualCanonicalizing([ + 'text', + 'textarea', + 'email', + 'url', + 'phone', + 'number', + 'decimal', + 'boolean', + 'date', + 'datetime', + 'select', + 'multiselect', + ]); +}); + +it('tolerates a built in type the consumer registered first', function (): void { + $manager = new CustomFields; + $manager->registerType(TextType::class); + + app()->instance(CustomFields::class, $manager); + + expect(fn (): array => app(CustomFields::class)->types())->not->toThrow(InvalidArgumentException::class); +}); + +it('rejects an unsupported key type while registering', function (): void { + config()->set('laravel-custom-fields.key_type', 'snowflake'); + + expect(fn (): mixed => (new CustomFieldsServiceProvider(app()))->register()) + ->toThrow(InvalidArgumentException::class, 'Unsupported custom fields key type [key_type].'); +}); + +it('rejects an unsupported morph key type while registering', function (): void { + config()->set('laravel-custom-fields.morph_key_type', 'snowflake'); + + expect(fn (): mixed => (new CustomFieldsServiceProvider(app()))->register()) + ->toThrow(InvalidArgumentException::class, 'Unsupported custom fields key type [morph_key_type].'); +}); + +it('loads the package translations in english', function (): void { + app()->setLocale('en'); + + expect(trans('laravel-custom-fields::messages.validation.invalid_option', ['option' => 'legacy', 'attribute' => 'status'])) + ->toBe('The selected option legacy is invalid for status.') + ->and(trans('laravel-custom-fields::messages.validation.unknown_field', ['attribute' => 'rank'])) + ->toBe('The rank field is not defined for this model.') + ->and(trans('laravel-custom-fields::messages.validation.inactive_field', ['attribute' => 'rank'])) + ->toBe('The rank field is not active.'); +}); + +it('loads the package translations in italian', function (): void { + app()->setLocale('it'); + + expect(trans('laravel-custom-fields::messages.validation.invalid_option', ['option' => 'legacy', 'attribute' => 'status'])) + ->toBe('L’opzione selezionata legacy non è valida per status.') + ->and(trans('laravel-custom-fields::messages.validation.unknown_field', ['attribute' => 'rank'])) + ->toBe('Il campo rank non è definito per questo modello.') + ->and(trans('laravel-custom-fields::messages.validation.inactive_field', ['attribute' => 'rank'])) + ->toBe('Il campo rank non è attivo.'); +}); + +it('keeps the facade annotations in step with the manager', function (): void { + $reflection = new ReflectionClass(CustomFieldsFacade::class); + preg_match_all('/@method static [^ ]+(?:<[^>]*>|\{[^}]*\})? (\w+)\(/', (string) $reflection->getDocComment(), $matches); + + $documented = $matches[1]; + $public = array_values(array_map( + static fn (ReflectionMethod $method): string => $method->getName(), + array_filter( + (new ReflectionClass(CustomFields::class))->getMethods(ReflectionMethod::IS_PUBLIC), + static fn (ReflectionMethod $method): bool => ! $method->isStatic() && ! $method->isConstructor(), + ), + )); + + expect($documented)->toEqualCanonicalizing($public); +}); diff --git a/tests/Feature/ValidationTest.php b/tests/Feature/ValidationTest.php new file mode 100644 index 0000000..8b65e61 --- /dev/null +++ b/tests/Feature/ValidationTest.php @@ -0,0 +1,212 @@ + $attributes */ +function definition(array $attributes): CustomField +{ + return CustomField::create($attributes + ['entity_type' => 'article', 'type' => 'text']); +} + +/** @return array> */ +function errorsOf(Closure $callback): array +{ + try { + $callback(); + } catch (ValidationException $exception) { + return $exception->errors(); + } + + return []; +} + +it('accepts a partial write that leaves a required field out', function (): void { + definition(['name' => 'Email', 'type' => 'email', 'is_required' => true]); + definition(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + + $article->setCustomField('rank', 3); + + expect($article->getCustomField('rank'))->toBe(3); +}); + +it('accepts a complete write when the required field is already stored', function (): void { + definition(['name' => 'Email', 'type' => 'email', 'is_required' => true]); + definition(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + $article->setCustomField('email', 'person@example.com'); + + $article->setCustomFields(['rank' => 3], complete: true); + + expect($article->getCustomFields())->toBe([ + 'email' => 'person@example.com', + 'rank' => 3, + ]); +}); + +it('refuses a complete write that clears a required field', function (): void { + definition(['name' => 'Email', 'type' => 'email', 'is_required' => true]); + $article = Article::create(['title' => 'A']); + $article->setCustomField('email', 'person@example.com'); + + expect(errorsOf(fn () => $article->setCustomFields(['email' => null], complete: true))) + ->toBe(['email' => ['The email field is required.']]) + ->and($article->getCustomField('email'))->toBe('person@example.com'); +}); + +it('keeps a stored value out of the input rules of its own type', function (): void { + CustomFields::registerType(RatingType::class); + definition(['name' => 'Score', 'type' => 'rating']); + definition(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + $article->setCustomField('score', 4); + + $article->setCustomFields(['rank' => 3], complete: true); + + expect($article->getCustomField('score'))->toBe('4 stars') + ->and($article->getCustomField('rank'))->toBe(3); +}); + +it('satisfies a required field of a type that reads back another shape', function (): void { + CustomFields::registerType(RatingType::class); + definition(['name' => 'Score', 'type' => 'rating', 'is_required' => true]); + definition(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + $article->setCustomField('score', 4); + + $article->setCustomFields(['rank' => 3], complete: true); + + expect(errorsOf(fn () => Article::create(['title' => 'B'])->setCustomFields(['rank' => 3], complete: true))) + ->toBe(['score' => ['The score field is required.']]); +}); + +it('refuses a complete write when a required field was never stored', function (): void { + definition(['name' => 'Email', 'type' => 'email', 'is_required' => true]); + definition(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + + expect(errorsOf(fn () => $article->setCustomFields(['rank' => 3], complete: true))) + ->toBe(['email' => ['The email field is required.']]) + ->and($article->customFieldValues()->count())->toBe(0); +}); + +it('validates a custom value before writing it', function (): void { + definition(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + + expect(fn () => $article->setCustomField('rank', 'invalid')) + ->toThrow(ValidationException::class); +}); + +it('names the field rather than the slug in an error message', function (): void { + definition(['name' => 'Data Di Nascita', 'type' => 'date', 'is_required' => true]); + $article = Article::create(['title' => 'A']); + + expect(errorsOf(fn () => $article->setCustomFields([], complete: true))) + ->toBe(['data-di-nascita' => ['The data di nascita field is required.']]); +}); + +it('keeps an inactive selected option readable but blocks new assignments', function (): void { + $field = definition(['name' => 'Status', 'type' => 'select', 'options' => [ + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => true], + ['key' => 'current', 'label' => 'Current', 'is_active' => true], + ]]); + $article = Article::create(['title' => 'A']); + $article->setCustomField('status', 'legacy'); + + $field->update(['options' => [ + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => false], + ['key' => 'current', 'label' => 'Current', 'is_active' => true], + ]]); + + expect($article->getCustomField('status'))->toBe('legacy') + ->and(errorsOf(fn () => Article::create(['title' => 'B'])->setCustomField('status', 'legacy'))) + ->toBe(['status' => ['The selected option legacy is invalid for status.']]); +}); + +it('reports an inactive option in the locale of the request', function (): void { + $field = definition(['name' => 'Status', 'type' => 'select', 'options' => [ + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => true], + ['key' => 'current', 'label' => 'Current', 'is_active' => true], + ]]); + $field->update(['options' => [ + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => false], + ['key' => 'current', 'label' => 'Current', 'is_active' => true], + ]]); + $article = Article::create(['title' => 'A']); + + app()->setLocale('it'); + + expect(errorsOf(fn () => $article->setCustomField('status', 'legacy'))) + ->toBe(['status' => ['L’opzione selezionata legacy non è valida per status.']]); +}); + +it('blocks a new assignment of an inactive option inside a multiselect', function (): void { + $field = definition(['name' => 'Tags', 'type' => 'multiselect', 'options' => [ + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => true], + ['key' => 'current', 'label' => 'Current', 'is_active' => true], + ]]); + $holder = Article::create(['title' => 'A']); + $holder->setCustomField('tags', ['legacy']); + $field->update(['options' => [ + ['key' => 'legacy', 'label' => 'Legacy', 'is_active' => false], + ['key' => 'current', 'label' => 'Current', 'is_active' => true], + ]]); + + expect($holder->getCustomField('tags'))->toBe(['legacy']); + + $holder->setCustomField('tags', ['legacy', 'current']); + + expect($holder->getCustomField('tags'))->toBe(['legacy', 'current']) + ->and(errorsOf(fn () => Article::create(['title' => 'B'])->setCustomField('tags', ['legacy']))) + ->toBe(['tags' => ['The selected option legacy is invalid for tags.']]); +}); + +it('refuses an option key that was never defined', function (): void { + definition(['name' => 'Status', 'type' => 'select', 'options' => [ + ['key' => 'current', 'label' => 'Current', 'is_active' => true], + ]]); + $article = Article::create(['title' => 'A']); + + expect(errorsOf(fn () => $article->setCustomField('status', 'ghost'))) + ->toHaveKey('status'); +}); + +it('validates a payload through the manager without preloaded state', function (): void { + definition(['name' => 'Email', 'type' => 'email', 'is_required' => true]); + definition(['name' => 'Rank', 'type' => 'number']); + $article = Article::create(['title' => 'A']); + $article->setCustomField('email', 'person@example.com'); + + CustomFields::validate($article, ['rank' => 3], complete: true); + + expect(errorsOf(fn () => CustomFields::validate($article, ['rank' => 'nope']))) + ->toHaveKey('rank'); +}); + +it('builds rules for the active definitions only', function (): void { + definition(['name' => 'Rank', 'type' => 'number']); + definition(['name' => 'Legacy', 'type' => 'text', 'is_active' => false]); + $validator = CustomFields::validator(); + + expect(array_keys($validator->rules(Article::class)))->toBe(['rank']) + ->and(array_keys($validator->definitions(Article::class)))->toEqualCanonicalizing(['rank', 'legacy']); +}); + +it('adds the required rule only to a complete validation', function (): void { + definition(['name' => 'Email', 'type' => 'email', 'is_required' => true]); + $validator = CustomFields::validator(); + + expect($validator->rules(Article::class))->toBe(['email' => ['nullable', 'email', 'max:255']]) + ->and($validator->rules(Article::class, complete: true))->toBe(['email' => ['required', 'email', 'max:255']]); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 8cad81b..5627d4b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,28 +4,91 @@ namespace PlinCode\CustomFields\Tests; +use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Orchestra\Testbench\TestCase as Orchestra; use PlinCode\CustomFields\CustomFieldsServiceProvider; +use PlinCode\EloquentSorts\EloquentSortsServiceProvider; +use Spatie\QueryBuilder\QueryBuilderServiceProvider; abstract class TestCase extends Orchestra { protected function getPackageProviders($app): array { return [ + QueryBuilderServiceProvider::class, + EloquentSortsServiceProvider::class, CustomFieldsServiceProvider::class, ]; } + /** + * The suite always runs against an in memory database. + * + * Without this, a database/database.sqlite left behind by composer build + * makes Testbench prefer the file, and the package migrations it published + * into the skeleton then collide with the ones loaded below. + */ + protected function defineEnvironment($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + } + protected function defineDatabaseMigrations(): void { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + Schema::create('authors', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + }); + Schema::create('articles', function (Blueprint $table): void { + $table->id(); + $table->foreignId('author_id')->nullable(); + $table->string('title'); + $table->timestamps(); + }); + + Schema::create('projects', function (Blueprint $table): void { $table->id(); $table->string('title'); $table->timestamps(); }); } + + /** + * Recreates the package tables under the key configuration the test just set. + * + * The key types have to be chosen before the migrations run, so a test that + * exercises uuid or ulid keys rebuilds the two tables through the very + * migrations the consumer publishes. + */ + protected function rebuildPackageTables(): void + { + $migrations = []; + + foreach ((array) glob(__DIR__.'/../database/migrations/*.php') as $file) { + $migration = require $file; + + if ($migration instanceof Migration) { + $migrations[] = $migration; + } + } + + foreach (array_reverse($migrations) as $migration) { + $migration->down(); + } + + foreach ($migrations as $migration) { + $migration->up(); + } + } } diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 4d06a51..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Unit/FieldTypeTest.php b/tests/Unit/FieldTypeTest.php new file mode 100644 index 0000000..6bdeb91 --- /dev/null +++ b/tests/Unit/FieldTypeTest.php @@ -0,0 +1,167 @@ +|null $options + */ +function typedField(string $type, ?array $options = null): CustomField +{ + return CustomField::create([ + 'entity_type' => 'article', + 'name' => $type.' field', + 'type' => $type, + 'options' => $options, + ]); +} + +$options = [ + ['key' => 'alpha', 'label' => 'Alpha', 'is_active' => true], + ['key' => 'beta', 'label' => 'Beta', 'is_active' => true], +]; + +it('round trips a value of every built in type', function (string $type, ?array $options, mixed $input, mixed $expected, string $column): void { + $field = typedField($type, $options); + $article = Article::create(['title' => 'A']); + + $article->setCustomField($field->slug, $input); + $row = $article->customFieldValues()->first(); + + expect($field->fieldType()->storageColumn())->toBe($column) + ->and($row?->getAttribute($column))->not->toBeNull() + ->and($article->getCustomField($field->slug))->toBe($expected); +})->with([ + 'text' => ['text', null, 'Sector', 'Sector', 'value_string'], + 'textarea' => ['textarea', null, str_repeat('long ', 200), str_repeat('long ', 200), 'value_text'], + 'email' => ['email', null, 'person@example.com', 'person@example.com', 'value_string'], + 'url' => ['url', null, 'https://example.com/a?b=c', 'https://example.com/a?b=c', 'value_string'], + 'phone' => ['phone', null, '+39 02 1234 5678', '+39 02 1234 5678', 'value_string'], + 'number' => ['number', null, 42, 42, 'value_integer'], + 'decimal' => ['decimal', null, '1234.567891', '1234.567891', 'value_decimal'], + 'boolean' => ['boolean', null, true, true, 'value_boolean'], + 'date' => ['date', null, '2024-01-31', '2024-01-31', 'value_date'], + 'datetime' => ['datetime', null, '2024-01-31 12:30:45', '2024-01-31 12:30:45', 'value_datetime'], + 'select' => ['select', $options, 'alpha', 'alpha', 'value_string'], + 'multiselect' => ['multiselect', $options, ['alpha', 'beta'], ['alpha', 'beta'], 'value_json'], +]); + +it('spreads the twelve built in types over the eight storage columns', function (): void { + $columns = array_map( + static fn (object $type): string => $type->storageColumn(), + CustomFields::types(), + ); + + expect($columns)->toHaveCount(12) + ->and(array_values(array_unique($columns)))->toEqualCanonicalizing([ + 'value_string', + 'value_text', + 'value_integer', + 'value_decimal', + 'value_boolean', + 'value_date', + 'value_datetime', + 'value_json', + ]); +}); + +it('keeps the scale of a decimal value', function (): void { + $field = typedField('decimal'); + $article = Article::create(['title' => 'A']); + + $article->setCustomField($field->slug, 12.5); + + expect($article->getCustomField($field->slug))->toBe('12.5'); +}); + +it('keeps a value at the top of the bigint range', function (): void { + $field = typedField('number'); + $article = Article::create(['title' => 'A']); + + $article->setCustomField($field->slug, PHP_INT_MAX); + + expect($article->getCustomField($field->slug))->toBe(PHP_INT_MAX); +}); + +it('reads a boolean false as a value rather than as an absence', function (): void { + $field = typedField('boolean'); + $article = Article::create(['title' => 'A']); + + $article->setCustomField($field->slug, false); + + expect($article->getCustomField($field->slug))->toBeFalse() + ->and($article->customFieldValues()->count())->toBe(1); +}); + +it('returns the default of the type when nothing is stored', function (): void { + typedField('number'); + typedField('multiselect', [['key' => 'alpha', 'label' => 'Alpha', 'is_active' => true]]); + $article = Article::create(['title' => 'A']); + + expect($article->getCustomFields())->toBe([ + 'number-field' => null, + 'multiselect-field' => [], + ]); +}); + +it('holds a text value to the length its column can take', function (): void { + $text = typedField('text'); + $textarea = typedField('textarea'); + $article = Article::create(['title' => 'A']); + + $article->setCustomField($textarea->slug, str_repeat('a', 300)); + + expect($article->getCustomField($textarea->slug))->toHaveLength(300) + ->and(fn () => $article->setCustomField($text->slug, str_repeat('a', 256))) + ->toThrow(ValidationException::class); +}); + +it('accepts a field type a consumer registers on its own', function (): void { + CustomFields::registerType(RatingType::class); + $field = typedField('rating'); + $article = Article::create(['title' => 'A']); + + $article->setCustomField($field->slug, 4); + + expect($field->fieldType())->toBeInstanceOf(RatingType::class) + ->and($article->customFieldValues()->first()?->getAttribute('value_integer'))->toBe(4) + ->and($article->getCustomField($field->slug))->toBe('4 stars') + ->and(Article::create(['title' => 'B'])->getCustomField($field->slug))->toBe('0 stars') + ->and(fn () => $article->setCustomField($field->slug, 9))->toThrow(ValidationException::class); +}); + +it('exposes only the operations of a consumer type the core can serve', function (): void { + CustomFields::registerType(RatingType::class); + typedField('rating'); + + $names = array_map( + static fn (object $filter): string => (string) $filter->getName(), + CustomFields::filtersFor(Article::class), + ); + + expect($names)->toBe(['cf_rating-field', 'cf_rating-field:between']) + ->and(CustomFields::filterName('rating-field', RatingType::WITHIN_REACH))->toBe('cf_rating-field:within_reach') + ->and(CustomFieldFilter::supports(RatingType::WITHIN_REACH))->toBeFalse(); +}); + +it('refuses to register the same type key twice', function (): void { + CustomFields::registerType(RatingType::class); + + expect(fn () => CustomFields::registerType(RatingType::class)) + ->toThrow(InvalidArgumentException::class, 'Custom field type [rating] is already registered.'); +}); + +it('refuses to resolve a type that was never registered', function (): void { + expect(fn (): object => CustomFields::type('rating')) + ->toThrow(InvalidArgumentException::class, 'Unknown custom field type [rating].'); +}); diff --git a/workbench/app/CustomFields/RatingType.php b/workbench/app/CustomFields/RatingType.php new file mode 100644 index 0000000..d3b9376 --- /dev/null +++ b/workbench/app/CustomFields/RatingType.php @@ -0,0 +1,65 @@ + "ulid" schema. */ +class UlidDocument extends Model +{ + use HasCustomFields; + use HasUlids; + + protected $guarded = []; +} diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index f87531b..86edfbd 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -3,20 +3,34 @@ namespace Workbench\App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; -use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Workbench\Database\Factories\UserFactory; -#[Fillable(['name', 'email', 'password'])] -#[Hidden(['password', 'remember_token'])] class User extends Authenticatable { /** @use HasFactory */ use HasFactory, Notifiable; + /** + * The attributes that are mass assignable. + * + * Declared as properties rather than through the Fillable and Hidden + * attributes, which only exist from Laravel 13 while this package + * supports Laravel 12 as well. + * + * @var list + */ + protected $fillable = ['name', 'email', 'password']; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = ['password', 'remember_token']; + /** * Get the attributes that should be cast. * diff --git a/workbench/app/Models/UuidDocument.php b/workbench/app/Models/UuidDocument.php new file mode 100644 index 0000000..715a722 --- /dev/null +++ b/workbench/app/Models/UuidDocument.php @@ -0,0 +1,18 @@ + "uuid" schema. */ +class UuidDocument extends Model +{ + use HasCustomFields; + use HasUuids; + + protected $guarded = []; +}