From 153dc6bf2953b58cf0bc38c31876c00bf69a8b20 Mon Sep 17 00:00:00 2001 From: Belisoful Date: Thu, 22 Oct 2026 17:05:51 -0700 Subject: [PATCH 1/4] TPropertyAccess: ArrayAccess docblocks and offset method tests (#6) --- .../SqlMap/DataMapper/TPropertyAccess.php | 13 ++-- .../SqlMap/DataMapper/TPropertyAccessTest.php | 70 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/Data/SqlMap/DataMapper/TPropertyAccess.php b/src/Data/SqlMap/DataMapper/TPropertyAccess.php index 7165652..1ce7e53 100644 --- a/src/Data/SqlMap/DataMapper/TPropertyAccess.php +++ b/src/Data/SqlMap/DataMapper/TPropertyAccess.php @@ -10,8 +10,6 @@ namespace Prado\Data\SqlMap\DataMapper; -use Prado\Exceptions\TInvalidDataValueException; - /** * TPropertyAccess class provides dot notation stype property access and setting. * @@ -38,6 +36,11 @@ * echo TPropertyAccess::get($obj, 'property1.property2.property3'); * ``` * + * Each segment of the path is resolved by the type of the current value: + * - array → the key, which must exist + * - `\ArrayAccess` object → `offsetExists()` and `offsetGet()` + * - other object → the `get`/`set` method, then the public property, then `__get`/`__set` + * * @author Wei Zhuo * @since 3.1 */ @@ -47,7 +50,7 @@ class TPropertyAccess * Gets the property value. * @param mixed $object object or path. * @param string $path property path. - * @throws TInvalidDataValueException if property path is invalid. + * @throws TInvalidPropertyException if property path is invalid. * @return mixed property value. */ public static function get($object, $path) @@ -88,6 +91,7 @@ public static function get($object, $path) } /** + * Checks whether every segment of the property path resolves. * @param mixed $object object or array * @param string $path property path. * @return bool true if property path is valid @@ -134,7 +138,8 @@ public static function has($object, $path) * @param mixed $originalObject by reference, object or array * @param string $path property path. * @param mixed $value new property value. - * @throws TInvalidDataValueException if property path is invalid. + * @throws TInvalidPropertyException if property path is invalid or the + * target is not an array or object. */ public static function set(&$originalObject, $path, $value) { diff --git a/tests/unit/Data/SqlMap/DataMapper/TPropertyAccessTest.php b/tests/unit/Data/SqlMap/DataMapper/TPropertyAccessTest.php index cd42436..7c9fc8d 100644 --- a/tests/unit/Data/SqlMap/DataMapper/TPropertyAccessTest.php +++ b/tests/unit/Data/SqlMap/DataMapper/TPropertyAccessTest.php @@ -213,19 +213,89 @@ public function testArrayAccessObject() self::assertNull(TPropertyAccess::get($map, 'n')); self::assertEquals('bar', TPropertyAccess::get($map, 'c.d')); self::assertTrue(TPropertyAccess::has($map, 'a')); + self::assertTrue(TPropertyAccess::has($map, 'n')); self::assertTrue(TPropertyAccess::has($map, 'c.d')); self::assertFalse(TPropertyAccess::has($map, 'z')); self::assertFalse(TPropertyAccess::has($map, 'c.z')); + self::assertFalse(TPropertyAccess::has($map, 'n.z')); + + TPropertyAccess::set($map, 'e', 'qux'); + self::assertEquals('qux', $map['e']); $testobj = new _PropertyAccessTestHelperPublicVar(); TPropertyAccess::set($testobj, 'a', $map); self::assertEquals('foo', TPropertyAccess::get($testobj, 'a.a')); + self::assertTrue(TPropertyAccess::has($testobj, 'a.c.d')); TPropertyAccess::set($testobj, 'a.b', 'baz'); self::assertEquals('baz', $map['b']); self::expectException(Prado\Data\SqlMap\DataMapper\TInvalidPropertyException::class); TPropertyAccess::get($map, 'z'); } + + public function testArrayAccessObjectNestedPathMissing() + { + $map = new \Prado\Collections\TMap(['c' => ['d' => 'bar']]); + + self::expectException(Prado\Data\SqlMap\DataMapper\TInvalidPropertyException::class); + TPropertyAccess::get($map, 'c.z'); + } + + public function testArrayAccessObjectUsesOffsetMethods() + { + $object = new _PropertyAccessTestHelperArrayAccess(); + + self::assertEquals(['a', 'b'], TPropertyAccess::get($object, 'ab')); + self::assertEquals('b', TPropertyAccess::get($object, 'ab.1')); + self::assertTrue(TPropertyAccess::has($object, 'ab')); + self::assertTrue(TPropertyAccess::has($object, 'ab.1')); + self::assertFalse(TPropertyAccess::has($object, 'cd')); + self::assertSame(['ab', 'ab', 'ab', 'ab', 'cd'], $object->existsCalls); + self::assertSame(['ab', 'ab', 'ab', 'ab'], $object->getCalls); + + self::expectException(Prado\Data\SqlMap\DataMapper\TInvalidPropertyException::class); + TPropertyAccess::get($object, 'cd'); + } + + public function testArrayAccessObjectInsideArray() + { + $map = new \Prado\Collections\TMap(['k' => 'v']); + $data = ['map' => $map]; + + self::assertEquals('v', TPropertyAccess::get($data, 'map.k')); + self::assertTrue(TPropertyAccess::has($data, 'map.k')); + self::assertFalse(TPropertyAccess::has($data, 'map.z')); + } +} + +/** + * Accepts only keys made of the letters a and b. The value of a key is the + * array of its letters. Records each offset call. + */ +class _PropertyAccessTestHelperArrayAccess implements \ArrayAccess +{ + public $existsCalls = []; + public $getCalls = []; + + public function offsetExists($offset): bool + { + $this->existsCalls[] = $offset; + return preg_match('/^[ab]+$/', (string) $offset) === 1; + } + + public function offsetGet($offset): mixed + { + $this->getCalls[] = $offset; + return str_split((string) $offset); + } + + public function offsetSet($offset, $value): void + { + } + + public function offsetUnset($offset): void + { + } } From 1bc32f3566106a918482c6d098da3187c6e65931 Mon Sep 17 00:00:00 2001 From: Belisoful Date: Thu, 22 Oct 2026 17:05:51 -0700 Subject: [PATCH 2/4] cacheModel on write statements: docs, cross-file and database tests (#5) --- docs/configuration/cacheModel.md | 16 +++++++++++++-- docs/statements/delete.md | 1 + docs/statements/insert.md | 3 ++- docs/statements/update.md | 1 + tests/unit/Data/SqlMap/CacheTest.php | 16 +++++++++++++++ .../TSqlMapCacheModelFlushTest.php | 20 +++++++++++++++++-- tests/unit/Data/SqlMap/maps/MySql/Account.xml | 10 ++++++++++ .../Data/SqlMap/maps/cache-flush-config.xml | 7 +++++++ .../Data/SqlMap/maps/cache-flush-other.xml | 6 ++++++ tests/unit/Data/SqlMap/maps/cache-flush.xml | 3 +++ .../Data/SqlMap/maps/firebird/Account.xml | 10 ++++++++++ tests/unit/Data/SqlMap/maps/ibm/Account.xml | 10 ++++++++++ tests/unit/Data/SqlMap/maps/mssql/Account.xml | 10 ++++++++++ .../unit/Data/SqlMap/maps/oracle/Account.xml | 10 ++++++++++ tests/unit/Data/SqlMap/maps/pgsql/Account.xml | 10 ++++++++++ .../unit/Data/SqlMap/maps/sqlite/Account.xml | 10 ++++++++++ 16 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 tests/unit/Data/SqlMap/maps/cache-flush-config.xml create mode 100644 tests/unit/Data/SqlMap/maps/cache-flush-other.xml diff --git a/docs/configuration/cacheModel.md b/docs/configuration/cacheModel.md index 4fb0aea..79ab9e4 100644 --- a/docs/configuration/cacheModel.md +++ b/docs/configuration/cacheModel.md @@ -2,7 +2,7 @@ # cacheModel -A `cacheModel` defines a named cache that [`select`](../mapping/select.md) statements reference to avoid repeated database round-trips. Results returned by a cached select are stored in the cache; subsequent calls with the same statement and parameters return the cached value without hitting the database. +A `cacheModel` defines a named cache that [`select`](../mapping/select.md) statements reference to avoid repeated database round-trips. Results returned by a cached select are stored in the cache; subsequent calls with the same statement and parameters return the cached value without hitting the database. An `insert`, `update`, or `delete` statement that references the cache model flushes it when it executes. Cache models are declared inside a [mapping file](../mapping/README.md) and are global to the [`TSqlMapManager`](../api/TSqlMapManager.md) instance. @@ -32,7 +32,7 @@ Cache models are declared inside a [mapping file](../mapping/README.md) and are | Attribute | Required | Values | Description | |---|---|---|---| -| `id` | yes | string | Unique identifier for this cache model within the SqlMap. Referenced by `cacheModel` on select statements. | +| `id` | yes | string | Unique identifier for this cache model within the SqlMap. Referenced by the `cacheModel` attribute of statements. | | `implementation` | no | `LRU`, `FIFO`, `Basic` | Cache strategy. Defaults to `Basic` ([`TSqlMapApplicationCache`](../api/TSqlMapApplicationCache.md)). | | `readOnly` | no | `true`/`false` | Reserved for future use; not currently enforced by the runtime. | | `serialize` | no | `true`/`false` | Reserved for future use; not currently enforced by the runtime. | @@ -106,6 +106,18 @@ Add the `cacheModel` attribute to a `select` statement to enable caching for it. When the same statement is executed with the same parameter, the result is returned from the cache. The cache key is derived from the statement id and a hash of the parameter object. +## Attaching to a write statement + +The `cacheModel` attribute on an `insert`, `update`, or `delete` statement flushes the named cache model when the statement executes. It has the same effect as a `flushOnExecute` element that names the statement. The cache model may be declared in any mapping file. + +```xml + + UPDATE accounts SET account_email = #EmailAddress# WHERE account_id = #Id# + +``` + ## APPLICATION cache The `Basic` implementation delegates to the PRADO application cache (`Prado::getApplication()->getCache()`). This requires that an [`ICache`](https://github.com/pradosoft/prado) component — such as `TMemCache` or `TDbCache` — is configured in the application and that [`TSqlMapApplicationCache`](../api/TSqlMapApplicationCache.md) is available. diff --git a/docs/statements/delete.md b/docs/statements/delete.md index f7eda86..d9c39b5 100644 --- a/docs/statements/delete.md +++ b/docs/statements/delete.md @@ -25,6 +25,7 @@ The body is the SQL text to execute. [Inline parameters](../dynamic-sql/inline-p | `id` | Yes | Unique identifier for the statement within its namespace. Passed to [`TSqlMapGateway::delete()`](../../src/Data/SqlMap/TSqlMapGateway.php) as the first argument. | | `parameterClass` | No | Fully qualified PHP class name (or primitive alias) of the input parameter. When omitted, the framework infers the type from the value passed at runtime. | | `parameterMap` | No | References a named [``](../mappings/parameterMap.md) that defines explicit column-to-property bindings. `parameterClass` and `parameterMap` are mutually exclusive; prefer `parameterClass` with inline parameters for simple cases. | +| `cacheModel` | No | Name of a [``](../configuration/cacheModel.md) that is flushed when the delete executes. See [Cache invalidation](#cache-invalidation). | --- diff --git a/docs/statements/insert.md b/docs/statements/insert.md index f2421e0..939e7f6 100644 --- a/docs/statements/insert.md +++ b/docs/statements/insert.md @@ -43,8 +43,9 @@ Full example showing both `pre` and `post` key retrieval patterns: | `id` | Yes | Unique identifier within the namespace. Passed to [`TSqlMapGateway::insert()`](#tsqlmapgatewayinsert) as `$statementName`. | | `parameterClass` | No | Fully qualified PHP class name (or primitive alias) of the parameter object. Used when no explicit `parameterMap` is needed; SqlMap maps `#property#` tokens directly to object properties. | | `parameterMap` | No | Reference to a named [``](../mappings/parameter-map.md). Takes precedence over `parameterClass` when both are present. Used for positional `?` placeholders or explicit type/null-value control. | +| `cacheModel` | No | Name of a [``](../configuration/cacheModel.md) that is flushed when the insert executes. | -The `insert` element does not support `resultClass`, `resultMap`, or `listClass`. It returns only a generated key or `null`. A `cacheModel` attribute names a cache model that the insert flushes when it executes. +The `insert` element does not support `resultClass`, `resultMap`, or `listClass`. It returns only a generated key or `null`. --- diff --git a/docs/statements/update.md b/docs/statements/update.md index 987b619..91ca73d 100644 --- a/docs/statements/update.md +++ b/docs/statements/update.md @@ -24,6 +24,7 @@ The SQL body is a plain SQL `UPDATE` string. Inline parameter tokens (`#property | `id` | Yes | Unique name for this statement within the mapping file. Used as the key passed to `TSqlMapGateway::update()`. | | `parameterClass` | No | Fully qualified class name (or [type alias](../configuration/README.md#typealias)) of the parameter object. When omitted, SqlMap accepts any object or associative array. | | `parameterMap` | No | Name of a [`parameterMap`](../mappings/README.md#parametermap) defined in this or another mapping file. Mutually exclusive with inline `#property#` tokens; use one or the other. | +| `cacheModel` | No | Name of a [`cacheModel`](../configuration/cacheModel.md) that is flushed when the update executes. See [Cache invalidation](#cache-invalidation-via-flushonexecute). | `update` does not support `resultClass` or `resultMap` — the only return value is the affected-row count. diff --git a/tests/unit/Data/SqlMap/CacheTest.php b/tests/unit/Data/SqlMap/CacheTest.php index 9682f51..0bad31a 100644 --- a/tests/unit/Data/SqlMap/CacheTest.php +++ b/tests/unit/Data/SqlMap/CacheTest.php @@ -92,6 +92,22 @@ public function testFlushDataCacheOnExecute() $this->assertTrue($list1 !== $list3); } + /** + * An update with a cacheModel attribute flushes that cache when it executes. + */ + public function testFlushDataCacheOnExecuteViaCacheModelAttribute() + { + $list1 = self::$sqlmap->queryForList("GetCachedAccountsViaResultMap"); + $list2 = self::$sqlmap->queryForList("GetCachedAccountsViaResultMap"); + + $this->assertTrue($list1 === $list2); + self::$sqlmap->update("UpdateAccountViaCacheModel", $list1[0]); + + $list3 = self::$sqlmap->queryForList("GetCachedAccountsViaResultMap"); + + $this->assertTrue($list1 !== $list3); + } + /** */ protected function getCacheModel() diff --git a/tests/unit/Data/SqlMap/Configuration/TSqlMapCacheModelFlushTest.php b/tests/unit/Data/SqlMap/Configuration/TSqlMapCacheModelFlushTest.php index 147d534..888a216 100644 --- a/tests/unit/Data/SqlMap/Configuration/TSqlMapCacheModelFlushTest.php +++ b/tests/unit/Data/SqlMap/Configuration/TSqlMapCacheModelFlushTest.php @@ -9,7 +9,7 @@ class TSqlMapCacheModelFlushTest extends PHPUnit\Framework\TestCase protected function setUp(): void { $this->manager = new TSqlMapManager(); - $this->manager->configureXml(__DIR__ . '/../maps/cache-flush.xml'); + $this->manager->configureXml(__DIR__ . '/../maps/cache-flush-config.xml'); } private function executes(string $statementID, string $cacheID): bool @@ -38,9 +38,25 @@ public function testStatementWithoutCacheModelDoesNotFlushCache() $this->assertFalse($this->executes('UpdateUncached', 'shared-cache')); } + public function testWriteStatementFlushesOnlyItsOwnCacheModel() + { + $this->assertFalse($this->executes('UpdateCached', 'listed-cache')); + $this->assertFalse($this->executes('UpdateListed', 'shared-cache')); + } + public function testFlushOnExecuteFlushesCache() { $this->assertTrue($this->executes('UpdateListed', 'listed-cache')); - $this->assertFalse($this->executes('UpdateCached', 'listed-cache')); + } + + public function testStatementListedAndWithCacheModelFlushesCache() + { + $this->assertTrue($this->executes('UpdateListedAndCached', 'listed-cache')); + } + + public function testCacheModelInAnotherMappingFile() + { + $this->assertTrue($this->executes('UpdateInOtherFile', 'shared-cache')); + $this->assertTrue($this->executes('DeleteOtherFileCache', 'other-cache')); } } diff --git a/tests/unit/Data/SqlMap/maps/MySql/Account.xml b/tests/unit/Data/SqlMap/maps/MySql/Account.xml index d6dd1c1..495c21e 100644 --- a/tests/unit/Data/SqlMap/maps/MySql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/MySql/Account.xml @@ -405,6 +405,16 @@ Account_Id = #Id# + + update Accounts set + Account_FirstName = #FirstName#, + Account_LastName = #LastName#, + Account_Email = #EmailAddress, dbType=VarChar, nullValue=no_email@provided.com# + where + Account_Id = #Id# + + insert into Accounts diff --git a/tests/unit/Data/SqlMap/maps/cache-flush-config.xml b/tests/unit/Data/SqlMap/maps/cache-flush-config.xml new file mode 100644 index 0000000..83fdd5b --- /dev/null +++ b/tests/unit/Data/SqlMap/maps/cache-flush-config.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/unit/Data/SqlMap/maps/cache-flush-other.xml b/tests/unit/Data/SqlMap/maps/cache-flush-other.xml new file mode 100644 index 0000000..58ccc43 --- /dev/null +++ b/tests/unit/Data/SqlMap/maps/cache-flush-other.xml @@ -0,0 +1,6 @@ + + + + + UPDATE t SET a = 5 + diff --git a/tests/unit/Data/SqlMap/maps/cache-flush.xml b/tests/unit/Data/SqlMap/maps/cache-flush.xml index 22efad0..37c812d 100644 --- a/tests/unit/Data/SqlMap/maps/cache-flush.xml +++ b/tests/unit/Data/SqlMap/maps/cache-flush.xml @@ -3,6 +3,7 @@ + @@ -11,4 +12,6 @@ DELETE FROM t UPDATE t SET a = 2 UPDATE t SET a = 3 + UPDATE t SET a = 4 + DELETE FROM t WHERE a = 5 diff --git a/tests/unit/Data/SqlMap/maps/firebird/Account.xml b/tests/unit/Data/SqlMap/maps/firebird/Account.xml index 3297b89..cca28ec 100644 --- a/tests/unit/Data/SqlMap/maps/firebird/Account.xml +++ b/tests/unit/Data/SqlMap/maps/firebird/Account.xml @@ -405,6 +405,16 @@ Account_Id = #Id# + + update Accounts set + Account_FirstName = #FirstName#, + Account_LastName = #LastName#, + Account_Email = #EmailAddress, dbType=VarChar, nullValue=no_email@provided.com# + where + Account_Id = #Id# + + insert into Accounts diff --git a/tests/unit/Data/SqlMap/maps/ibm/Account.xml b/tests/unit/Data/SqlMap/maps/ibm/Account.xml index ec94190..a16d2bb 100644 --- a/tests/unit/Data/SqlMap/maps/ibm/Account.xml +++ b/tests/unit/Data/SqlMap/maps/ibm/Account.xml @@ -405,6 +405,16 @@ Account_Id = #Id# + + update Accounts set + Account_FirstName = #FirstName#, + Account_LastName = #LastName#, + Account_Email = #EmailAddress, dbType=VarChar, nullValue=no_email@provided.com# + where + Account_Id = #Id# + + insert into Accounts diff --git a/tests/unit/Data/SqlMap/maps/mssql/Account.xml b/tests/unit/Data/SqlMap/maps/mssql/Account.xml index f37562c..d17d389 100644 --- a/tests/unit/Data/SqlMap/maps/mssql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/mssql/Account.xml @@ -348,6 +348,16 @@ Account_ID = #Id# + + update Accounts set + Account_FirstName = #FirstName#, + Account_LastName = #LastName#, + Account_Email = #EmailAddress, nullValue=no_email@provided.com# + where + Account_ID = #Id# + + insert into Accounts diff --git a/tests/unit/Data/SqlMap/maps/oracle/Account.xml b/tests/unit/Data/SqlMap/maps/oracle/Account.xml index da2035f..0a9a775 100644 --- a/tests/unit/Data/SqlMap/maps/oracle/Account.xml +++ b/tests/unit/Data/SqlMap/maps/oracle/Account.xml @@ -405,6 +405,16 @@ Account_Id = #Id# + + update Accounts set + Account_FirstName = #FirstName#, + Account_LastName = #LastName#, + Account_Email = #EmailAddress, dbType=VarChar, nullValue=no_email@provided.com# + where + Account_Id = #Id# + + insert into Accounts diff --git a/tests/unit/Data/SqlMap/maps/pgsql/Account.xml b/tests/unit/Data/SqlMap/maps/pgsql/Account.xml index 4a41455..581e871 100644 --- a/tests/unit/Data/SqlMap/maps/pgsql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/pgsql/Account.xml @@ -405,6 +405,16 @@ Account_Id = #Id# + + update Accounts set + Account_FirstName = #FirstName#, + Account_LastName = #LastName#, + Account_Email = #EmailAddress, dbType=VarChar, nullValue=no_email@provided.com# + where + Account_Id = #Id# + + insert into Accounts diff --git a/tests/unit/Data/SqlMap/maps/sqlite/Account.xml b/tests/unit/Data/SqlMap/maps/sqlite/Account.xml index aa17545..5b651f5 100644 --- a/tests/unit/Data/SqlMap/maps/sqlite/Account.xml +++ b/tests/unit/Data/SqlMap/maps/sqlite/Account.xml @@ -405,6 +405,16 @@ Account_Id = #Id# + + update Accounts set + Account_FirstName = #FirstName#, + Account_LastName = #LastName#, + Account_Email = #EmailAddress, dbType=VarChar, nullValue=no_email@provided.com# + where + Account_Id = #Id# + + insert into Accounts From 78e3656d0d611e2b78411c373646e0bfa8dcbe37 Mon Sep 17 00:00:00 2001 From: Belisoful Date: Thu, 22 Oct 2026 17:05:51 -0700 Subject: [PATCH 3/4] Scope nested groupBy keys by their parent; LEFT JOIN coverage (#4) --- CHANGELOG.md | 1 + docs/mappings/resultMap.md | 1 + .../SqlMap/Statements/TMappedStatement.php | 10 +- tests/unit/Data/SqlMap/GroupByTest.php | 18 +++ .../TMappedStatementGroupByTest.php | 148 ++++++++++++++++-- tests/unit/Data/SqlMap/maps/MySql/Account.xml | 6 + .../Data/SqlMap/maps/firebird/Account.xml | 23 +++ tests/unit/Data/SqlMap/maps/group-by.xml | 14 +- tests/unit/Data/SqlMap/maps/ibm/Account.xml | 23 +++ tests/unit/Data/SqlMap/maps/mssql/Account.xml | 6 + .../unit/Data/SqlMap/maps/oracle/Account.xml | 23 +++ tests/unit/Data/SqlMap/maps/pgsql/Account.xml | 23 +++ .../unit/Data/SqlMap/maps/sqlite/Account.xml | 23 +++ 13 files changed, 304 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa08d0..3c77336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,4 +31,5 @@ First standalone release, planned as 1.0.0, for PRADO 4.4. SqlMap moves out of t - `TParameterProperty::__sleep()` and `TResultProperty::__sleep()` name private properties by their namespaced class, so properties at their default values are left out of the serialized form. `TResultProperty` also misspelled `_propertyName`. - `TResultProperty::getPropertyValueType()` no longer calls a method on `null` when no reflection class is found for the type. - A `groupBy` result map no longer adds an element to a nested collection when every column of the nested result map is null, as in a LEFT JOIN row with no match. The collection stays empty. A parent row with no nested elements is still returned, in query order (#4). +- A nested result map with its own `groupBy` groups within its parent. The same `groupBy` value under two parents previously shared one tree key, so the second parent lost the element and the first parent's element was replaced. - `TPropertyAccess::get()` and `has()` read `ArrayAccess` objects through `offsetExists()` and `offsetGet()`. `array_key_exists()` threw a `TypeError` for these objects on PHP 8 (#6). diff --git a/docs/mappings/resultMap.md b/docs/mappings/resultMap.md index 26fd3dc..f6a898b 100644 --- a/docs/mappings/resultMap.md +++ b/docs/mappings/resultMap.md @@ -136,6 +136,7 @@ A single SQL `JOIN` query can produce multiple rows per parent entity (one-to-ma - `groupBy` lists one or more property names whose combined values identify a unique parent row. - The child collection property must have `resultMapping` pointing to a separate `` for the child entity. - The collection property type must be `TList`, `array`, or another list-compatible type. +- A child `` may have its own `groupBy` and nested collections. Its `groupBy` values are grouped within the parent, so the same child value under two parents gives two child objects. **Example — Account with Orders:** diff --git a/src/Data/SqlMap/Statements/TMappedStatement.php b/src/Data/SqlMap/Statements/TMappedStatement.php index 1c575c5..69236f6 100644 --- a/src/Data/SqlMap/Statements/TMappedStatement.php +++ b/src/Data/SqlMap/Statements/TMappedStatement.php @@ -666,9 +666,11 @@ protected function fillResultMap($resultMapName, $row, $parentGroup = null, &$re * ResultMap with GroupBy property. Save object collection graph in a tree * and collect the result later. A nested result map without row data, such as * the right side of a LEFT JOIN with no match, adds no element to the collection. + * The tree key of a nested element is prefixed with its parent key, so equal + * `groupBy` values under different parents are separate elements. * @param TResultMap $resultMap result mapping details. * @param array $row a result set row retrieved from the database - * @param mixed $parent + * @param mixed $parent tree key of the parent element, null or '' for a root * @param object $resultObject the result object * @return object result object. */ @@ -679,6 +681,9 @@ protected function addResultMapGroupBy($resultMap, $row, $parent, &$resultObject if (empty($parent)) { $rootObject = ['object' => $resultObject, 'property' => null]; $this->_groupBy->add(null, $group, $rootObject); + $parentKey = $group; + } else { + $parentKey = $parent; } foreach ($resultMap->getColumns() as $property) { @@ -692,7 +697,7 @@ protected function addResultMapGroupBy($resultMap, $row, $parent, &$resultObject if (!$this->hasResultMapRowData($nestedMap, $row)) { continue; } - $groupKey = $this->getResultMapGroupKey($nestedMap, $row); + $groupKey = $parentKey . '.' . $this->getResultMapGroupKey($nestedMap, $row); //add the node reference first if (empty($parent)) { @@ -722,6 +727,7 @@ protected function addResultMapGroupBy($resultMap, $row, $parent, &$resultObject * @param TResultMap $resultMap result mapping details. * @param array $row a result set row retrieved from the database * @return bool true if a mapped column is not null or the map has no column mappings. + * @since 4.4.0 */ protected function hasResultMapRowData($resultMap, $row) { diff --git a/tests/unit/Data/SqlMap/GroupByTest.php b/tests/unit/Data/SqlMap/GroupByTest.php index 236e5b1..990f830 100644 --- a/tests/unit/Data/SqlMap/GroupByTest.php +++ b/tests/unit/Data/SqlMap/GroupByTest.php @@ -34,4 +34,22 @@ public function testAccountWithOrders() $this->assertSame(2, count($account->getOrders())); } } + + /** + * Orders 1, 2 and 3 belong to accounts 1, 4 and 3. The LEFT JOIN limited to + * those orders leaves accounts 2 and 5 with null in every order column. + */ + public function testAccountWithoutOrdersHasEmptyCollection() + { + $this->initScript('account-init.sql'); + $accounts = self::$sqlmap->queryForList("getAccountWithEarlyOrders"); + $this->assertSame(5, count($accounts)); + + $orders = []; + foreach ($accounts as $account) { + $orders[(int) $account->getId()] = array_map(fn ($order) => (int) $order->getId(), $account->getOrders()); + } + ksort($orders); + $this->assertSame([1 => [1], 2 => [], 3 => [3], 4 => [2], 5 => []], $orders); + } } diff --git a/tests/unit/Data/SqlMap/Statements/TMappedStatementGroupByTest.php b/tests/unit/Data/SqlMap/Statements/TMappedStatementGroupByTest.php index 031cd49..a69bea4 100644 --- a/tests/unit/Data/SqlMap/Statements/TMappedStatementGroupByTest.php +++ b/tests/unit/Data/SqlMap/Statements/TMappedStatementGroupByTest.php @@ -1,5 +1,8 @@ configureXml(__DIR__ . '/../maps/group-by.xml'); - $statement = $manager->getMappedStatement('GetUsersWithRoles'); + return $manager; + } + + private function query(array $rows): array + { + $statement = $this->manager()->getMappedStatement('GetUsersWithRoles'); return $statement->runQueryForList( new TMappedStatementGroupByConnection(), null, @@ -53,13 +68,48 @@ private function roleNames(TMappedStatementGroupByUser $user): array return array_map(fn ($role) => $role->Name, $user->Roles); } + private function permissionNames(TMappedStatementGroupByRole $role): array + { + return array_map(fn ($permission) => $permission->Name, $role->Permissions); + } + + private function hasRowData(TResultMap $resultMap, array $row): bool + { + $manager = new TSqlMapManager(); + $statement = new TMappedStatement($manager, new \Prado\Data\SqlMap\Configuration\TSqlMapSelect()); + $check = \Closure::bind( + fn ($resultMap, $row) => $this->hasResultMapRowData($resultMap, $row), + $statement, + TMappedStatement::class + ); + return $check($resultMap, $row); + } + + private function resultMap(array $columns): TResultMap + { + $resultMap = new TResultMap(); + $resultMap->setID('map'); + foreach ($columns as $property => $column) { + $result = new TResultProperty(); + $result->setProperty($property); + if (is_int($column)) { + $result->setColumnIndex($column); + $result->setColumn('col' . $column); + } elseif ($column !== null) { + $result->setColumn($column); + } + $resultMap->addResultProperty($result); + } + return $resultMap; + } + public function testLeftJoinWithoutMatchGivesEmptyCollection() { $users = $this->query([ - ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'admin'], - ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'user'], - ['user_id' => 2, 'user_name' => 'bob', 'role_name' => null], - ['user_id' => 3, 'user_name' => 'cy', 'role_name' => 'user'], + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'admin', 'role_level' => 2, 'permission_name' => 'read'], + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'user', 'role_level' => 1, 'permission_name' => 'read'], + ['user_id' => 2, 'user_name' => 'bob', 'role_name' => null, 'role_level' => null, 'permission_name' => null], + ['user_id' => 3, 'user_name' => 'cy', 'role_name' => 'user', 'role_level' => 1, 'permission_name' => 'read'], ]); $this->assertSame(['ann', 'bob', 'cy'], array_map(fn ($user) => $user->Name, $users)); @@ -71,8 +121,8 @@ public function testLeftJoinWithoutMatchGivesEmptyCollection() public function testLeftJoinWithoutAnyMatch() { $users = $this->query([ - ['user_id' => 1, 'user_name' => 'ann', 'role_name' => null], - ['user_id' => 2, 'user_name' => 'bob', 'role_name' => null], + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => null, 'role_level' => null, 'permission_name' => null], + ['user_id' => 2, 'user_name' => 'bob', 'role_name' => null, 'role_level' => null, 'permission_name' => null], ]); $this->assertSame(['ann', 'bob'], array_map(fn ($user) => $user->Name, $users)); @@ -80,14 +130,92 @@ public function testLeftJoinWithoutAnyMatch() $this->assertSame([], $users[1]->Roles); } + public function testNestedRowWithOneValueIsKept() + { + $users = $this->query([ + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => null, 'role_level' => 3, 'permission_name' => null], + ]); + + $this->assertCount(1, $users[0]->Roles); + $this->assertNull($users[0]->Roles[0]->Name); + $this->assertSame(3, $users[0]->Roles[0]->Level); + $this->assertSame([], $users[0]->Roles[0]->Permissions); + } + + public function testLeftJoinWithoutMatchAtSecondLevel() + { + $users = $this->query([ + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'admin', 'role_level' => 2, 'permission_name' => 'read'], + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'admin', 'role_level' => 2, 'permission_name' => 'write'], + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'guest', 'role_level' => 0, 'permission_name' => null], + ['user_id' => 2, 'user_name' => 'bob', 'role_name' => null, 'role_level' => null, 'permission_name' => null], + ]); + + $this->assertSame(['ann', 'bob'], array_map(fn ($user) => $user->Name, $users)); + $this->assertSame(['admin', 'guest'], $this->roleNames($users[0])); + $this->assertSame(['read', 'write'], $this->permissionNames($users[0]->Roles[0])); + $this->assertSame([], $users[0]->Roles[1]->Permissions); + $this->assertSame([], $users[1]->Roles); + } + + public function testSameNestedGroupValueUnderDifferentParents() + { + $users = $this->query([ + ['user_id' => 1, 'user_name' => 'ann', 'role_name' => 'user', 'role_level' => 1, 'permission_name' => 'read'], + ['user_id' => 2, 'user_name' => 'bob', 'role_name' => 'user', 'role_level' => 1, 'permission_name' => 'read'], + ['user_id' => 2, 'user_name' => 'bob', 'role_name' => 'user', 'role_level' => 1, 'permission_name' => 'write'], + ]); + + $this->assertSame(['user'], $this->roleNames($users[0])); + $this->assertSame(['user'], $this->roleNames($users[1])); + $this->assertNotSame($users[0]->Roles[0], $users[1]->Roles[0]); + $this->assertSame(['read'], $this->permissionNames($users[0]->Roles[0])); + $this->assertSame(['read', 'write'], $this->permissionNames($users[1]->Roles[0])); + } + public function testColumnCaseFolding() { $users = $this->query([ - ['USER_ID' => 1, 'USER_NAME' => 'ann', 'ROLE_NAME' => 'admin'], - ['USER_ID' => 2, 'USER_NAME' => 'bob', 'ROLE_NAME' => null], + ['USER_ID' => 1, 'USER_NAME' => 'ann', 'ROLE_NAME' => 'admin', 'ROLE_LEVEL' => 2, 'PERMISSION_NAME' => null], + ['USER_ID' => 2, 'USER_NAME' => 'bob', 'ROLE_NAME' => null, 'ROLE_LEVEL' => null, 'PERMISSION_NAME' => null], ]); $this->assertSame(['admin'], $this->roleNames($users[0])); + $this->assertSame([], $users[0]->Roles[0]->Permissions); $this->assertSame([], $users[1]->Roles); } + + public function testHasResultMapRowDataByColumnName() + { + $map = $this->resultMap(['A' => 'col_a', 'B' => 'col_b']); + + $this->assertTrue($this->hasRowData($map, ['col_a' => null, 'col_b' => 0])); + $this->assertTrue($this->hasRowData($map, ['col_a' => '', 'col_b' => null])); + $this->assertFalse($this->hasRowData($map, ['col_a' => null, 'col_b' => null])); + $this->assertFalse($this->hasRowData($map, ['other' => 1])); + } + + public function testHasResultMapRowDataByColumnNameCaseInsensitive() + { + $map = $this->resultMap(['A' => 'Col_A']); + + $this->assertTrue($this->hasRowData($map, ['COL_A' => 1])); + $this->assertTrue($this->hasRowData($map, ['col_a' => 'x'])); + $this->assertFalse($this->hasRowData($map, ['COL_A' => null])); + } + + public function testHasResultMapRowDataByColumnIndex() + { + $map = $this->resultMap(['A' => 2]); + + $this->assertTrue($this->hasRowData($map, [1 => null, 2 => 'x'])); + $this->assertFalse($this->hasRowData($map, [1 => 'x', 2 => null])); + } + + public function testHasResultMapRowDataWithoutColumnMappings() + { + $this->assertTrue($this->hasRowData($this->resultMap([]), ['col_a' => null])); + $this->assertTrue($this->hasRowData($this->resultMap(['Nested' => null]), ['col_a' => null])); + $this->assertTrue($this->hasRowData($this->resultMap(['Nested' => '']), ['col_a' => null])); + } } diff --git a/tests/unit/Data/SqlMap/maps/MySql/Account.xml b/tests/unit/Data/SqlMap/maps/MySql/Account.xml index 495c21e..96ceddf 100644 --- a/tests/unit/Data/SqlMap/maps/MySql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/MySql/Account.xml @@ -566,6 +566,12 @@ Accounts.account_id = Orders.account_id + + diff --git a/tests/unit/Data/SqlMap/maps/firebird/Account.xml b/tests/unit/Data/SqlMap/maps/firebird/Account.xml index cca28ec..93271e7 100644 --- a/tests/unit/Data/SqlMap/maps/firebird/Account.xml +++ b/tests/unit/Data/SqlMap/maps/firebird/Account.xml @@ -583,6 +583,29 @@ accounts.account_id = orders.account_id + + diff --git a/tests/unit/Data/SqlMap/maps/group-by.xml b/tests/unit/Data/SqlMap/maps/group-by.xml index 8d935b4..93cb334 100644 --- a/tests/unit/Data/SqlMap/maps/group-by.xml +++ b/tests/unit/Data/SqlMap/maps/group-by.xml @@ -6,12 +6,20 @@ - + + + + + + + diff --git a/tests/unit/Data/SqlMap/maps/ibm/Account.xml b/tests/unit/Data/SqlMap/maps/ibm/Account.xml index a16d2bb..dcfd20a 100644 --- a/tests/unit/Data/SqlMap/maps/ibm/Account.xml +++ b/tests/unit/Data/SqlMap/maps/ibm/Account.xml @@ -583,6 +583,29 @@ accounts.account_id = orders.account_id + + diff --git a/tests/unit/Data/SqlMap/maps/mssql/Account.xml b/tests/unit/Data/SqlMap/maps/mssql/Account.xml index d17d389..bf1a372 100644 --- a/tests/unit/Data/SqlMap/maps/mssql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/mssql/Account.xml @@ -561,6 +561,12 @@ accounts.account_id = orders.account_id + + diff --git a/tests/unit/Data/SqlMap/maps/oracle/Account.xml b/tests/unit/Data/SqlMap/maps/oracle/Account.xml index 0a9a775..8139859 100644 --- a/tests/unit/Data/SqlMap/maps/oracle/Account.xml +++ b/tests/unit/Data/SqlMap/maps/oracle/Account.xml @@ -595,6 +595,29 @@ accounts.account_id = orders.account_id + + diff --git a/tests/unit/Data/SqlMap/maps/pgsql/Account.xml b/tests/unit/Data/SqlMap/maps/pgsql/Account.xml index 581e871..2020c65 100644 --- a/tests/unit/Data/SqlMap/maps/pgsql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/pgsql/Account.xml @@ -583,6 +583,29 @@ accounts.account_id = orders.account_id + + diff --git a/tests/unit/Data/SqlMap/maps/sqlite/Account.xml b/tests/unit/Data/SqlMap/maps/sqlite/Account.xml index 5b651f5..642d22c 100644 --- a/tests/unit/Data/SqlMap/maps/sqlite/Account.xml +++ b/tests/unit/Data/SqlMap/maps/sqlite/Account.xml @@ -583,6 +583,29 @@ accounts.account_id = orders.account_id + + From 28faf64395e1b4e9f19e54a40d0a81cc5272d61b Mon Sep 17 00:00:00 2001 From: Belisoful Date: Thu, 22 Oct 2026 17:10:15 -0700 Subject: [PATCH 4/4] GroupByTest: alias the joined columns in the MySQL and SQL Server maps (#4) --- tests/unit/Data/SqlMap/maps/MySql/Account.xml | 18 +++++++++++++++-- tests/unit/Data/SqlMap/maps/mssql/Account.xml | 20 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/unit/Data/SqlMap/maps/MySql/Account.xml b/tests/unit/Data/SqlMap/maps/MySql/Account.xml index 96ceddf..b9444b1 100644 --- a/tests/unit/Data/SqlMap/maps/MySql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/MySql/Account.xml @@ -567,9 +567,23 @@ diff --git a/tests/unit/Data/SqlMap/maps/mssql/Account.xml b/tests/unit/Data/SqlMap/maps/mssql/Account.xml index bf1a372..fbb2981 100644 --- a/tests/unit/Data/SqlMap/maps/mssql/Account.xml +++ b/tests/unit/Data/SqlMap/maps/mssql/Account.xml @@ -562,9 +562,23 @@