diff --git a/CHANGELOG-5.0.md b/CHANGELOG-5.0.md index 978c5099a3..24a51b2821 100644 --- a/CHANGELOG-5.0.md +++ b/CHANGELOG-5.0.md @@ -11,16 +11,17 @@ ### Added +- Added `beforeBind` and `afterBind` hook methods to `Phalcon\Forms\Form`. When defined on a form subclass, `beforeBind(array $data, ?object $entity)` runs at the start of `bind()` (returning `false` cancels the bind) and `afterBind(?object $entity)` runs after the data has been assigned. Both also fire when `bind()` is reached through `isValid()`. [#14598](https://github.com/phalcon/cphalcon/issues/14598) [[doc]](https://docs.phalcon.io/5.14/forms/) - Added a `sync` option to many-to-many (`hasManyToMany`) relations and a chainable `Phalcon\Mvc\Model::setSync()` method to synchronize related records on save. When enabled, saving deletes the intermediate rows for records no longer present in the assigned array (add/update/delete), instead of only appending. [#17071](https://github.com/phalcon/cphalcon/issues/17071) [[doc]](https://docs.phalcon.io/5.14/db-models-relationships/) - Added a `trace()` method to `Phalcon\Logger\Logger` together with a new `TRACE` log level (value `9`, label `trace`). [#17047](https://github.com/phalcon/cphalcon/issues/17047) [[doc]](https://docs.phalcon.io/5.14/logger/) ### Fixed +- Fixed PHQL parser cache to use string-keyed lookups (`zend_hash_str_find`/`zend_hash_str_update`) instead of integer keys derived from `zend_inline_hash_func`, eliminating hash collisions that caused different PHQL queries to return identical cached ASTs [#14791](https://github.com/phalcon/cphalcon/issues/14791) +- Fixed `Phalcon\Annotations\Reader` failing to parse a docblock when an annotation argument is a string literal containing a parenthesis (e.g. `@SomeAnnotation(key='value(')`). The docblock pre-scan that locates each `@Annotation(...)` span counted every `(`/`)`, including those inside quoted values, so an unbalanced parenthesis in a string consumed the rest of the comment and produced a "Scanning error". [#16084](https://github.com/phalcon/cphalcon/issues/16084) - Fixed `Phalcon\Di\Injectable::__get()` to no longer cache resolved services as dynamic object properties. Services accessed via magic properties (e.g. `$this->request`) are now re-resolved through the container on each access, so replacing or updating a service in the container is reflected in controllers, views, and other injectable classes. Properties already declared on the class continue to be populated. [#17052](https://github.com/phalcon/cphalcon/issues/17052) - Fixed `Phalcon\Mvc\Model\Query\Builder::orderBy()` when the array syntax is used with complex PHQL expressions. Previously any array item containing a space was split as a simple `column direction` pair, corrupting expressions such as `CASE WHEN inv_status_flag = 1 THEN 0 ELSE 1 END ASC`. The builder now only treats a trailing `ASC`/`DESC` as the direction (autoescaping a simple column) and preserves complex expressions verbatim. [#17077](https://github.com/phalcon/cphalcon/issues/17077) - Fixed `Phalcon\Mvc\Model\Query` (PHQL) parsing of identifiers whose name begins with the `NOT` keyword. Columns, tables, and aliases such as `notice_id` were truncated to `ice_id` (the leading `not` was dropped), causing the database to report the column as unknown - most visibly in `Phalcon\Mvc\Model\Query\Builder` join conditions built via `createBuilder()`. The scanner's re2c backtracking marker shared the token-start pointer, so the `NOT BETWEEN` rule advanced it past `not`; escaped identifiers containing internal escapes (e.g. `[col\[0\]]`) were corrupted by the same root cause. [#16831](https://github.com/phalcon/cphalcon/issues/16831) -- Fixed PHQL parser cache to use string-keyed lookups (`zend_hash_str_find`/`zend_hash_str_update`) instead of integer keys derived from `zend_inline_hash_func`, eliminating hash collisions that caused different PHQL queries to return identical cached ASTs [#14791](https://github.com/phalcon/cphalcon/issues/14791) -- Fixed `Phalcon\Annotations\Reader` failing to parse a docblock when an annotation argument is a string literal containing a parenthesis (e.g. `@SomeAnnotation(key='value(')`). The docblock pre-scan that locates each `@Annotation(...)` span counted every `(`/`)`, including those inside quoted values, so an unbalanced parenthesis in a string consumed the rest of the comment and produced a "Scanning error". [#16084](https://github.com/phalcon/cphalcon/issues/16084) - Fixed the compilation failure (`'name_zv' undeclared`) in `Phalcon\Container\Container::callableGet()` and `callableNew()`. Both closures captured the typed `string name` parameter directly via `use (name)`. [#17078](https://github.com/phalcon/cphalcon/issues/17078) ### Removed diff --git a/phalcon/Forms/Form.zep b/phalcon/Forms/Form.zep index 91a065b9a4..3df4a98c96 100644 --- a/phalcon/Forms/Form.zep +++ b/phalcon/Forms/Form.zep @@ -203,6 +203,15 @@ class Form extends Injectable implements Countable, Iterator, AttributesInterfac throw new NoFormElements(); } + /** + * Check if there is a method 'beforeBind' + */ + if method_exists(this, "beforeBind") { + if this->{"beforeBind"}(data, entity) === false { + return this; + } + } + if empty whitelist { let whitelist = this->whitelist; } @@ -306,6 +315,13 @@ class Form extends Injectable implements Countable, Iterator, AttributesInterfac let this->data = assignData; let this->filteredData = filteredData; + /** + * Check if there is a method 'afterBind' + */ + if method_exists(this, "afterBind") { + this->{"afterBind"}(entity); + } + return this; } diff --git a/tests/unit/Forms/Fake/FakeFormAfterBind.php b/tests/unit/Forms/Fake/FakeFormAfterBind.php new file mode 100644 index 0000000000..166c6465dd --- /dev/null +++ b/tests/unit/Forms/Fake/FakeFormAfterBind.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\Forms\Fake; + +use Phalcon\Forms\Form; + +class FakeFormAfterBind extends Form +{ + public bool $afterBindCalled = false; + + public function afterBind(?object $entity): void + { + $this->afterBindCalled = true; + } +} diff --git a/tests/unit/Forms/Fake/FakeFormBeforeBind.php b/tests/unit/Forms/Fake/FakeFormBeforeBind.php new file mode 100644 index 0000000000..58a7c2ab52 --- /dev/null +++ b/tests/unit/Forms/Fake/FakeFormBeforeBind.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\Forms\Fake; + +use Phalcon\Forms\Form; + +class FakeFormBeforeBind extends Form +{ + public function beforeBind(array $data, ?object $entity): bool + { + return false; + } +} diff --git a/tests/unit/Forms/Form/BindTest.php b/tests/unit/Forms/Form/BindTest.php index 3eaa2fd377..87dbca8f92 100644 --- a/tests/unit/Forms/Form/BindTest.php +++ b/tests/unit/Forms/Form/BindTest.php @@ -19,6 +19,8 @@ use Phalcon\Forms\Exception; use Phalcon\Forms\Form; use Phalcon\Tests\AbstractUnitTestCase; +use Phalcon\Tests\Unit\Forms\Fake\FakeFormAfterBind; +use Phalcon\Tests\Unit\Forms\Fake\FakeFormBeforeBind; use stdClass; /** @@ -65,6 +67,43 @@ public function testFormsFormBind(): void $this->assertEquals("test1", $form->getValue("test1")); } + /** + * Tests that the afterBind() hook is invoked after bind() assigns data. + * + * @issue https://github.com/phalcon/cphalcon/issues/14598 + * @author Phalcon Team + * @since 2026-06-05 + */ + public function testFormsFormBindAfterBindCalled(): void + { + $form = new FakeFormAfterBind(); + $form->add(new Text('name')); + + $form->bind(['name' => 'test'], new stdClass()); + + $this->assertTrue($form->afterBindCalled); + } + + /** + * Tests that returning false from beforeBind() cancels the bind so the + * entity is left untouched. + * + * @issue https://github.com/phalcon/cphalcon/issues/14598 + * @author Phalcon Team + * @since 2026-06-05 + */ + public function testFormsFormBindBeforeBindReturnsFalseAbortsBind(): void + { + $form = new FakeFormBeforeBind(); + $form->add(new Text('name')); + + $entity = new stdClass(); + $entity->name = 'original'; + $form->bind(['name' => 'changed'], $entity); + + $this->assertSame('original', $entity->name); + } + /** * Issue #15957 - Radio elements registered under distinct form-element * identifiers but sharing the same HTML "name" attribute must bind to