diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index c4e40b5c53..f8db436ca7 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -18,30 +18,14 @@ bin/cake upgrade rector --rules cakephp54 ### I18n -``Number::parseFloat()`` now returns ``null`` instead of ``0.0`` when parsing -fails. Previously, when ``NumberFormatter::parse()`` failed it returned ``false``, -which was cast to ``0.0``. This silently converted invalid input like ``"abc"`` -to ``0.0``, making it impossible to distinguish from valid ``"0"`` input. - -This also affects ``FloatType`` and ``DecimalType`` database types which use -``Number::parseFloat()`` internally. Invalid locale-formatted form input will -now result in ``null`` entity values instead of ``0``. +`Number::parseFloat()` now returns `null` instead of `0.0` when parsing +fails. This also affects `FloatType` and `DecimalType` database types. ### ORM The default eager loading strategy for `HasMany` and `BelongsToMany` associations -has changed from ``select`` to ``subquery``. The ``subquery`` strategy performs -better for larger datasets as it avoids packet size limits from large ``WHERE IN`` -clauses and reduces PHP memory usage by keeping IDs in the database. - -If you need the previous behavior, you can explicitly set the strategy when -defining associations: - -```php -$this->hasMany('Comments', [ - 'strategy' => 'select', -]); -``` +has changed from `select` to `subquery`. If you need the previous behavior, +explicitly set `'strategy' => 'select'` when defining associations. ## Deprecations @@ -49,18 +33,40 @@ $this->hasMany('Comments', [ ## New Features +### Controller + +- Added `#[RequestToDto]` attribute for automatic mapping of request data to + Data Transfer Objects in controller actions. + See [Request to DTO Mapping](../development/dependency-injection#request-to-dto-mapping). +- Added `unlockActions()` and `unlockFields()` convenience methods to + `FormProtectionComponent`. + See [Form Protection Component](../controllers/components/form-protection). + +### Database + +- Added `notBetween()` method for `NOT BETWEEN` expressions. + See [Query Builder](../orm/query-builder#advanced-conditions). +- Added `inOrNull()` and `notInOrNull()` methods for combining `IN` conditions with `IS NULL`. +- Added `isDistinctFrom()` and `isNotDistinctFrom()` methods for null-safe comparisons. + ### I18n -- `Number::toReadableSize()` now calculates decimal units (KB, MB, GB and TB) -using an exponent of ten, meaning that 1 KB is 1000 Bytes. The units from the -previous calculation method, where 1024 Bytes equaled 1 KB, have been changed -to KiB, MiB, GiB, and TiB as defined in ISO/IEC 80000-13. It is possible to -switch between the two units using a new optional boolean parameter in -`Number::toReadableSize()`, as well as the new global setter `Number::setUseIecUnits()`. +- `Number::toReadableSize()` now uses decimal units (KB = 1000 bytes) by default. + Binary units (KiB = 1024 bytes) can be enabled via parameter or `Number::setUseIecUnits()`. + +### ORM + +- The `associated` option in `newEntity()` and `patchEntity()` now supports + nested array format matching `contain()` syntax. + See [Converting Request Data into Entities](../orm/saving-data#converting-request-data-into-entities). ### Utility -- New `Cake\Utility\Fs\Finder` class provides a fluent, iterator-based API for - discovering files and directories with support for pattern matching, depth - control, and custom filters. The `Cake\Utility\Fs\Path` class offers - cross-platform utilities for path manipulation. +- Added `Cake\Utility\Fs\Finder` class for fluent file discovery with pattern matching, + depth control, and custom filters. Added `Cake\Utility\Fs\Path` for cross-platform + path manipulation. + +### View + +- Added `{{inputId}}` template variable to `inputContainer` and `error` templates + in FormHelper. See [Built-in Template Variables](../views/helpers/form#built-in-template-variables). diff --git a/docs/en/controllers/components/form-protection.md b/docs/en/controllers/components/form-protection.md index 7aa0e76c00..a9f8c1c23e 100644 --- a/docs/en/controllers/components/form-protection.md +++ b/docs/en/controllers/components/form-protection.md @@ -140,6 +140,51 @@ class WidgetController extends AppController This example would disable all security checks for the edit action. +You can also use the convenience method ``unlockActions()``: + +```php +public function beforeFilter(EventInterface $event): void +{ + parent::beforeFilter($event); + + // Unlock a single action + $this->FormProtection->unlockActions('edit'); + + // Unlock multiple actions + $this->FormProtection->unlockActions(['edit', 'api', 'webhook']); + + // Replace existing unlocked actions instead of merging + $this->FormProtection->unlockActions(['newAction'], merge: false); +} +``` + +::: info Added in version 5.4.0 +::: + +## Unlocking fields + +To unlock specific fields from validation, you can use the ``unlockFields()`` +convenience method: + +```php +public function beforeFilter(EventInterface $event): void +{ + parent::beforeFilter($event); + + // Unlock a single field + $this->FormProtection->unlockFields('dynamic_field'); + + // Unlock multiple fields + $this->FormProtection->unlockFields(['optional_field', 'ajax_field']); + + // Dot notation for nested fields + $this->FormProtection->unlockFields('user.preferences'); +} +``` + +::: info Added in version 5.4.0 +::: + ## Handling validation failure through callbacks If form protection validation fails it will result in a 400 error by default. diff --git a/docs/en/development/dependency-injection.md b/docs/en/development/dependency-injection.md index 0de78be3f6..2d99061301 100644 --- a/docs/en/development/dependency-injection.md +++ b/docs/en/development/dependency-injection.md @@ -425,6 +425,106 @@ database. Because this service is injected into our controller, we can easily swap the implementation out with a mock object or a dummy sub-class when testing. +## Request to DTO Mapping + +CakePHP supports automatic mapping of request data to Data Transfer Objects (DTOs) +using the `#[RequestToDto]` attribute. This provides a clean, type-safe way to +handle form data in controller actions: + +```php +use Cake\Controller\Attribute\RequestToDto; + +class UsersController extends AppController +{ + public function create(#[RequestToDto] UserCreateDto $dto): void + { + // $dto is automatically populated from request data + $user = $this->Users->newEntity([ + 'email' => $dto->email, + 'name' => $dto->name, + ]); + + if ($this->Users->save($user)) { + $this->Flash->success('User created'); + return $this->redirect(['action' => 'index']); + } + } +} +``` + +Your DTO class must implement a static `createFromArray()` method: + +```php +namespace App\Dto; + +class UserCreateDto +{ + public function __construct( + public string $email, + public string $name, + public ?string $phone = null, + ) { + } + + public static function createFromArray(array $data): self + { + return new self( + email: $data['email'] ?? '', + name: $data['name'] ?? '', + phone: $data['phone'] ?? null, + ); + } +} +``` + +### Configuring the Data Source + +By default, the attribute auto-detects the data source based on the request method +(query params for GET, body data for POST/PUT/PATCH). You can explicitly configure +the source using the `RequestToDtoSource` enum: + +```php +use Cake\Controller\Attribute\RequestToDto; +use Cake\Controller\Attribute\Enum\RequestToDtoSource; + +class ArticlesController extends AppController +{ + // Use query string parameters + public function search( + #[RequestToDto(source: RequestToDtoSource::Query)] SearchCriteriaDto $criteria + ): void { + $articles = $this->Articles->find() + ->where(['title LIKE' => "%{$criteria->query}%"]) + ->limit($criteria->limit); + } + + // Use POST body data explicitly + public function create( + #[RequestToDto(source: RequestToDtoSource::Body)] ArticleCreateDto $dto + ): void { + // ... + } + + // Merge query params and body data (body takes precedence) + public function update( + int $id, + #[RequestToDto(source: RequestToDtoSource::Request)] ArticleUpdateDto $dto + ): void { + // ... + } +} +``` + +The available source options are: + +- `RequestToDtoSource::Auto` - Auto-detect based on request method (default) +- `RequestToDtoSource::Query` - Use query string parameters +- `RequestToDtoSource::Body` - Use POST/PUT body data +- `RequestToDtoSource::Request` - Merge query params and body data + +::: info Added in version 5.4.0 +::: + ## Command Example ```php diff --git a/docs/en/orm/query-builder.md b/docs/en/orm/query-builder.md index 2ea7e22f1d..bb5215dd9f 100644 --- a/docs/en/orm/query-builder.md +++ b/docs/en/orm/query-builder.md @@ -1248,6 +1248,29 @@ conditions: # WHERE country_id NOT IN ('AFG', 'USA', 'EST') ``` +- `inOrNull()` Create a condition for `IN` combined with `IS NULL`: + + ```php + $query = $cities->find() + ->where(function (QueryExpression $exp, SelectQuery $q) { + return $exp->inOrNull('country_id', ['AFG', 'USA', 'EST']); + }); + # WHERE (country_id IN ('AFG', 'USA', 'EST') OR country_id IS NULL) + ``` + + ::: info Added in version 5.4.0 + ::: + +- `notInOrNull()` Create a condition for `NOT IN` combined with `IS NULL`: + + ```php + $query = $cities->find() + ->where(function (QueryExpression $exp, SelectQuery $q) { + return $exp->notInOrNull('country_id', ['AFG', 'USA', 'EST']); + }); + # WHERE (country_id NOT IN ('AFG', 'USA', 'EST') OR country_id IS NULL) + ``` + - `gt()` Create a `>` condition: ```php @@ -1318,6 +1341,19 @@ conditions: # WHERE population BETWEEN 999 AND 5000000, ``` +- `notBetween()` Create a `NOT BETWEEN` condition: + + ```php + $query = $cities->find() + ->where(function (QueryExpression $exp, SelectQuery $q) { + return $exp->notBetween('population', 999, 5000000); + }); + # WHERE population NOT BETWEEN 999 AND 5000000 + ``` + + ::: info Added in version 5.4.0 + ::: + - `exists()` Create a condition using `EXISTS`: ```php @@ -1352,6 +1388,43 @@ conditions: # WHERE NOT EXISTS (SELECT id FROM cities WHERE countries.id = cities.country_id AND population > 5000000) ``` +- `isDistinctFrom()` Create a null-safe inequality comparison using `IS DISTINCT FROM`: + + ```php + $query = $cities->find() + ->where(function (QueryExpression $exp, SelectQuery $q) { + return $exp->isDistinctFrom('status', 'active'); + }); + # WHERE status IS DISTINCT FROM 'active' + # MySQL uses: NOT (status <=> 'active') + ``` + + This is useful when you need to compare values where `NULL` should be treated + as a distinct value. Unlike regular `!=` comparisons, `IS DISTINCT FROM` + returns `TRUE` when comparing `NULL` to a non-NULL value, and `FALSE` when + comparing `NULL` to `NULL`. + + ::: info Added in version 5.4.0 + ::: + +- `isNotDistinctFrom()` Create a null-safe equality comparison using `IS NOT DISTINCT FROM`: + + ```php + $query = $cities->find() + ->where(function (QueryExpression $exp, SelectQuery $q) { + return $exp->isNotDistinctFrom('category_id', null); + }); + # WHERE category_id IS NOT DISTINCT FROM NULL + # MySQL uses: category_id <=> NULL + ``` + + This is the null-safe equivalent of `=`. It returns `TRUE` when both values + are `NULL` (unlike regular `=` which returns `NULL`), making it useful for + comparing nullable columns. + + ::: info Added in version 5.4.0 + ::: + Expression objects should cover many commonly used functions and expressions. If you find yourself unable to create the required conditions with expressions you can may be able to use `bind()` to manually bind parameters into conditions: diff --git a/docs/en/orm/saving-data.md b/docs/en/orm/saving-data.md index 01b71a1130..4e5b93d8cd 100644 --- a/docs/en/orm/saving-data.md +++ b/docs/en/orm/saving-data.md @@ -187,12 +187,47 @@ $articles = $this->fetchTable('Articles'); $entity = $articles->newEntity($this->request->getData(), [ 'associated' => [ 'Tags', 'Comments' => ['associated' => ['Users']], - ] + ], ]); ``` The above indicates that the 'Tags', 'Comments' and 'Users' for the Comments -should be marshalled. Alternatively, you can use dot notation for brevity: +should be marshalled. + +You can also use a nested array format similar to ``contain()``: + +```php +// Nested arrays (same format as contain()) +$entity = $articles->newEntity($this->request->getData(), [ + 'associated' => [ + 'Tags', + 'Comments' => [ + 'Users', + 'Attachments', + ], + ], +]); + +// Mixed with options +$entity = $articles->newEntity($this->request->getData(), [ + 'associated' => [ + 'Tags' => ['onlyIds' => true], + 'Comments' => [ + 'Users', + 'validate' => 'special', + ], + ], +]); +``` + +CakePHP distinguishes associations from options using naming conventions: +association names use PascalCase (e.g., ``Users``), while option keys use +camelCase (e.g., ``onlyIds``). + +::: info Added in version 5.4.0 +::: + +Alternatively, you can use dot notation for brevity: ```php // In a controller diff --git a/docs/en/views/helpers/form.md b/docs/en/views/helpers/form.md index f4517dc5bb..41945bfe15 100644 --- a/docs/en/views/helpers/form.md +++ b/docs/en/views/helpers/form.md @@ -2440,6 +2440,36 @@ Output: ``` +### Built-in Template Variables + +The `inputContainer` and `error` templates have access to a built-in `{{inputId}}` +variable containing the input element's HTML id attribute. This is useful for +generating related element IDs for ARIA attributes or custom JavaScript: + +```php +$this->Form->setTemplates([ + 'inputContainer' => '
{{content}}
', + 'error' => '', +]); + +// When rendering a 'username' field: +echo $this->Form->control('username'); +``` + +Output: + +```html +
+ + +
+``` + +This enables use cases like field-specific error containers for AJAX form validation. + +::: info Added in version 5.4.0 +::: + ### Moving Checkboxes & Radios Outside of a Label By default, CakePHP nests checkboxes created via `control()` and radio buttons