From 0080324fa0aa18502dd4be6d16b6a03da61a672f Mon Sep 17 00:00:00 2001 From: Belisoful Date: Wed, 4 Nov 2026 21:57:51 -0800 Subject: [PATCH 1/3] Align with PRADO Data 4.4: insertOrIgnore and upsert statements --- CHANGELOG.md | 8 +- config/classes.json | 4 + docs/README.md | 6 +- docs/statements/README.md | 4 +- docs/statements/upsert.md | 63 +++++++ .../SqlMap/Configuration/TDiscriminator.php | 5 +- .../Configuration/TParameterProperty.php | 5 +- .../SqlMap/Configuration/TResultProperty.php | 5 +- .../Configuration/TSqlMapInsertOrIgnore.php | 24 +++ .../SqlMap/Configuration/TSqlMapStatement.php | 10 +- .../SqlMap/Configuration/TSqlMapUpsert.php | 58 +++++++ .../Configuration/TSqlMapXmlConfiguration.php | 2 +- .../TSqlMapXmlMappingConfiguration.php | 155 ++++++++++++++++-- src/Data/SqlMap/Configuration/TSubMap.php | 3 + src/Data/SqlMap/DataMapper/messages.txt | 1 + .../SqlMap/Statements/IMappedStatement.php | 14 +- .../TInsertOrIgnoreMappedStatement.php | 26 +++ .../SqlMap/Statements/TMappedStatement.php | 36 ++-- .../SqlMap/Statements/TPreparedCommand.php | 38 ++++- .../SqlMap/Statements/TPreparedStatement.php | 5 +- .../TSqlMapObjectCollectionTree.php | 5 +- .../Statements/TUpsertMappedStatement.php | 28 ++++ src/Data/SqlMap/TSqlMapConfig.php | 1 + src/Data/SqlMap/TSqlMapGateway.php | 15 +- src/Data/SqlMap/TSqlMapManager.php | 3 + .../Configuration/TSqlMapUpsertTest.php | 86 ++++++++++ .../sqlite/SqliteUpsertStatementTest.php | 82 +++++++++ tests/unit/Data/SqlMap/maps/upsert-config.xml | 6 + tests/unit/Data/SqlMap/maps/upsert.xml | 30 ++++ 29 files changed, 656 insertions(+), 72 deletions(-) create mode 100644 docs/statements/upsert.md create mode 100644 src/Data/SqlMap/Configuration/TSqlMapInsertOrIgnore.php create mode 100644 src/Data/SqlMap/Configuration/TSqlMapUpsert.php create mode 100644 src/Data/SqlMap/Statements/TInsertOrIgnoreMappedStatement.php create mode 100644 src/Data/SqlMap/Statements/TUpsertMappedStatement.php create mode 100644 tests/unit/Data/SqlMap/Configuration/TSqlMapUpsertTest.php create mode 100644 tests/unit/Data/SqlMap/DbSpecific/sqlite/SqliteUpsertStatementTest.php create mode 100644 tests/unit/Data/SqlMap/maps/upsert-config.xml create mode 100644 tests/unit/Data/SqlMap/maps/upsert.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5e947..976e8e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,19 @@ First standalone release, planned as 1.0.0, for PRADO 4.4. SqlMap moves out of t ### Added +- `` and `` mapping elements, from PRADO's Data layer renovation for 4.4 (pradosoft/prado#1088). They run through `TSqlMapGateway::insert()` as an `` does, with the driver's own conflict SQL. `` takes `updateColumns` and `conflictColumns` attributes. Classes: `TSqlMapInsertOrIgnore`, `TSqlMapUpsert`, `TInsertOrIgnoreMappedStatement`, `TUpsertMappedStatement`. See [insertOrIgnore and upsert](docs/statements/upsert.md). - `TSqlMapManager::VERSION` holds the package version and `TSqlMapManager::getVersion()` (the `Version` property) returns the version that built the manager, in the form of `Prado::getVersion()`. The version is serialized with the manager. `TSqlMapConfig` discards a cached manager whose version is not `VERSION` and rebuilds it, so the application cache does not need to be cleared after an upgrade. - An ``, ``, or `` statement with a `cacheModel` attribute flushes that cache model when it executes, the same as a `` entry. The attribute was previously accepted and ignored (#5). - `composer.json` declares `extra.prado` for PRADO 4.4: `bootstrap` (`TSqlMapConfig`), `error-messages` (`src/Data/SqlMap/DataMapper/messages.txt`), and `class-map` (`config/classes.json`). The legacy top-level `extra.bootstrap` remains for PRADO 4.3. -- `config/classes.json` maps the 55 SqlMap short class names (`TSqlMapConfig`, `TSqlMapFifoCache`, ...) to their FQNs. It replaces the SqlMap entries of the framework's `classes.php`. +- `config/classes.json` maps the 59 SqlMap short class names (`TSqlMapConfig`, `TSqlMapFifoCache`, ...) to their FQNs. It replaces the SqlMap entries of the framework's `classes.php`. - Message text for five codes that were thrown without a definition: `sqlmap_can_not_extend_select_key`, `sqlmap_configfile_invalid`, `sqlmap_must_enable_custom_paging`, `sqlmap_property_must_be_list`, `sqlmap_use_set_to_store_cache`. - `TSqlMapException` accepts the `TException` argument forms: an optional integer error code first, and a trailing `Throwable` as the previous exception. ### Changed +- A parameter without a `dbType` binds with the type from PRADO 4.4's `TDbCommand::getColumnTypeFromValue()` when the command provides it; otherwise as before. +- Paging through a connection whose command builder is not an SQL builder throws `TSqlMapExecutionException` `sqlmap_limit_offset_unsupported`. +- The configuration and statement classes leave properties out of serialization through `_getZappableSleepProps()`, so `TComponent` also leaves out its default state. - `TSqlMapException::getErrorCode()` returns the message code (for example `sqlmap_contains_no_statement`). It previously returned the translated message text. - `messages.txt` entries are ICU MessageFormat patterns (pradosoft/prado#1437): `'{0}'` is `''{0}''`. Messages render as before. Under PRADO 4.3, `TSqlMapException::translateErrorMessage()` returns `''` as `'`. - Active Record support is the `pradosoft/prado-activerecord` package (pradosoft/prado#1359). It is a suggested package and a development dependency for the Active Record SqlMap tests. @@ -31,7 +35,7 @@ First standalone release, planned as 1.0.0, for PRADO 4.4. SqlMap moves out of t - Firebird: a boolean parameter binds as `0` or `1`. `pdo_firebird` binds a PHP boolean only to a `BOOLEAN` column. - `TCachingStatement` builds cache keys from the SQL text. It previously serialized the prepared command, which threw "Serialization of 'PDOStatement' is not allowed" for any cached statement with bound parameters. - `TCachingStatement::executeInsert()` delegates to the wrapped statement. It previously called itself until the stack overflowed. -- `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`. +- `TParameterProperty` and `TResultProperty` 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. diff --git a/config/classes.json b/config/classes.json index e98a457..bfd986c 100644 --- a/config/classes.json +++ b/config/classes.json @@ -11,10 +11,12 @@ "TSqlMapCacheTypes": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapCacheTypes", "TSqlMapDelete": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapDelete", "TSqlMapInsert": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapInsert", + "TSqlMapInsertOrIgnore": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapInsertOrIgnore", "TSqlMapSelect": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapSelect", "TSqlMapSelectKey": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapSelectKey", "TSqlMapStatement": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapStatement", "TSqlMapUpdate": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapUpdate", + "TSqlMapUpsert": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapUpsert", "TSqlMapXmlConfigBuilder": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapXmlConfigBuilder", "TSqlMapXmlConfiguration": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapXmlConfiguration", "TSqlMapXmlMappingConfiguration": "Prado\\Data\\SqlMap\\Configuration\\TSqlMapXmlMappingConfiguration", @@ -39,6 +41,7 @@ "TCachingStatement": "Prado\\Data\\SqlMap\\Statements\\TCachingStatement", "TDeleteMappedStatement": "Prado\\Data\\SqlMap\\Statements\\TDeleteMappedStatement", "TInsertMappedStatement": "Prado\\Data\\SqlMap\\Statements\\TInsertMappedStatement", + "TInsertOrIgnoreMappedStatement": "Prado\\Data\\SqlMap\\Statements\\TInsertOrIgnoreMappedStatement", "TMappedStatement": "Prado\\Data\\SqlMap\\Statements\\TMappedStatement", "TPostSelectBinding": "Prado\\Data\\SqlMap\\Statements\\TPostSelectBinding", "TPreparedCommand": "Prado\\Data\\SqlMap\\Statements\\TPreparedCommand", @@ -51,6 +54,7 @@ "TSqlMapObjectCollectionTree": "Prado\\Data\\SqlMap\\Statements\\TSqlMapObjectCollectionTree", "TStaticSql": "Prado\\Data\\SqlMap\\Statements\\TStaticSql", "TUpdateMappedStatement": "Prado\\Data\\SqlMap\\Statements\\TUpdateMappedStatement", + "TUpsertMappedStatement": "Prado\\Data\\SqlMap\\Statements\\TUpsertMappedStatement", "TSqlMapConfig": "Prado\\Data\\SqlMap\\TSqlMapConfig", "TSqlMapGateway": "Prado\\Data\\SqlMap\\TSqlMapGateway", "TSqlMapManager": "Prado\\Data\\SqlMap\\TSqlMapManager" diff --git a/docs/README.md b/docs/README.md index 8e425fd..64da80b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ SqlMap is a data-mapper layer that maps SQL statements defined in XML files to P | [Prado Integration](integration.md) | Registering `TSqlMapConfig` as a PRADO application module, connection configuration, and caching the parsed manager | | [Quick Start](quickstart.md) | Minimal working example: config file, mapping file, and PHP calls | | [Configuration](configuration/README.md) | `sqlMapConfig` root file reference — properties, type aliases, type handlers, transaction manager, data source, and `sqlMaps` includes | -| [Statements](statements/README.md) | `select`, `insert`, `update`, `delete`, `statement`, and `procedure` tags; `selectKey`; `TSqlMapGateway` PHP API | +| [Statements](statements/README.md) | `select`, `insert`, `insertOrIgnore`, `upsert`, `update`, `delete`, `statement`, and `procedure` tags; `selectKey`; `TSqlMapGateway` PHP API | | [Mappings](mappings/README.md) | `resultMap`, `parameterMap`, inline parameter syntax, discriminators, and sub-maps | | [Dynamic SQL](dynamic-sql/README.md) | `dynamic`, conditional tags (`isNull`, `isEqual`, `iterate`, etc.), and inline substitution tokens | @@ -43,7 +43,9 @@ SqlMap is a data-mapper layer that maps SQL statements defined in XML files to P | `parameter` | `parameterMap` | Declares one parameter: property, db type, null value, and mode | [Mappings](mappings/README.md#parameter) | | `select` | `sqlMap` | Mapped `SELECT` statement; returns one object or a list | [Statements](statements/README.md#select) | | `insert` | `sqlMap` | Mapped `INSERT` statement; optionally returns a generated key | [Statements](statements/README.md#insert) | -| `selectKey` | `insert` | Executes a query to retrieve the generated primary key | [Statements](statements/README.md#selectkey) | +| `insertOrIgnore` | `sqlMap` | Mapped `INSERT` that skips a conflicting row | [insertOrIgnore and upsert](statements/upsert.md) | +| `upsert` | `sqlMap` | Mapped `INSERT` that updates a conflicting row | [insertOrIgnore and upsert](statements/upsert.md) | +| `selectKey` | `insert`, `insertOrIgnore`, `upsert` | Executes a query to retrieve the generated primary key | [Statements](statements/README.md#selectkey) | | `update` | `sqlMap` | Mapped `UPDATE` statement; returns affected-row count | [Statements](statements/README.md#update) | | `delete` | `sqlMap` | Mapped `DELETE` statement; returns affected-row count | [Statements](statements/README.md#delete) | | `statement` | `sqlMap` | Generic mapped statement for any SQL that does not fit a specific DML tag | [Statements](statements/README.md#statement) | diff --git a/docs/statements/README.md b/docs/statements/README.md index 4810784..7afc4c2 100644 --- a/docs/statements/README.md +++ b/docs/statements/README.md @@ -2,7 +2,7 @@ # Statements -SqlMap supports five statement types for executing SQL: [`select`](select.md), [`insert`](insert.md), [`update`](update.md), [`delete`](delete.md), and [`statement`](statement.md) (generic). A sixth element, [`selectKey`](insert.md#selectkey), appears only as a child of `insert` and retrieves generated key values before or after the insert executes. +SqlMap supports seven statement types for executing SQL: [`select`](select.md), [`insert`](insert.md), [`insertOrIgnore` and `upsert`](upsert.md), [`update`](update.md), [`delete`](delete.md), and [`statement`](statement.md) (generic). The [`selectKey`](insert.md#selectkey) element appears only as a child of `insert`, `insertOrIgnore`, or `upsert` and retrieves generated key values before or after the insert executes. All statement elements share a common set of attributes, and all are defined inside a [mapping file](../mappings/README.md) as children of the root `` element. @@ -30,6 +30,8 @@ All statement elements share a common set of attributes, and all are defined ins |---|---|---| | [`select`](select.md) | `queryForObject`, `queryForList`, `queryForMap`, `queryForPagedList` | Reads rows; result mapping applied; supports caching. | | [`insert`](insert.md) | `insert` | Writes one row; optional `` child returns the generated key. | +| [`insertOrIgnore`](upsert.md) | `insert` | Writes one row unless it conflicts with an existing key. | +| [`upsert`](upsert.md) | `insert` | Writes one row, or updates the row it conflicts with. | | [`update`](update.md) | `update` | Modifies rows; returns affected-row count. | | [`delete`](delete.md) | `delete` | Removes rows; returns affected-row count. | | [`statement`](statement.md) | `queryForObject`, `queryForList`, `insert`, `update`, `delete` | Generic; use when the DML type is variable or unknown at mapping time. | diff --git a/docs/statements/upsert.md b/docs/statements/upsert.md new file mode 100644 index 0000000..b229105 --- /dev/null +++ b/docs/statements/upsert.md @@ -0,0 +1,63 @@ +[SqlMap Docs](../README.md) -> [Statements](README.md) -> insertOrIgnore and upsert + +# insertOrIgnore and upsert + +The `insertOrIgnore` element maps an `INSERT` that skips a row conflicting with an existing key. The `upsert` element maps an `INSERT` that updates the conflicting row. Both run through `TSqlMapGateway::insert()` and behave as [`insert`](insert.md): they take the same attributes, accept a [`selectKey`](selectKey.md) child, return the `selectKey` value or `null`, and flush the `cacheModel` they name. + +The SQL in the element is the driver's own conflict syntax. SqlMap does not rewrite it. + +| Driver | `insertOrIgnore` | `upsert` | +|---|---|---| +| SQLite | `INSERT OR IGNORE INTO ...` | `INSERT ... ON CONFLICT (col) DO UPDATE SET ...` | +| MySQL | `INSERT IGNORE INTO ...` | `INSERT ... ON DUPLICATE KEY UPDATE ...` | +| PostgreSQL | `INSERT ... ON CONFLICT DO NOTHING` | `INSERT ... ON CONFLICT (col) DO UPDATE SET ...` | +| SQL Server, Oracle, DB2, Firebird | `MERGE` without `WHEN MATCHED` | `MERGE ... WHEN MATCHED THEN UPDATE ...` | + +--- + +## Syntax + +```xml + + + INSERT OR IGNORE INTO tags (name) VALUES (#name#) + + + + INSERT INTO scores (player, score, updated) VALUES (#player#, #score#, #updated#) + ON CONFLICT (player) DO UPDATE SET score = excluded.score, updated = excluded.updated + + +``` + +```php +$gateway->insert('addTag', $tag); +$gateway->insert('saveScore', $score); +``` + +--- + +## Attributes + +`insertOrIgnore` takes the attributes of [`insert`](insert.md#attributes). `upsert` adds two: + +| Attribute | Type | Description | +|---|---|---| +| `updateColumns` | comma-separated list | Columns the statement updates on a conflict. Stored as `TSqlMapUpsert::getUpdateColumns()`; `null` when absent. | +| `conflictColumns` | comma-separated list | Columns that detect the conflict. Stored as `TSqlMapUpsert::getConflictColumns()`; `null` when absent. | + +The two attributes describe the statement for code that reads the mapping. They do not change the SQL. + +--- + +## Classes + +| Element | Statement class | Mapped statement class | +|---|---|---| +| `insertOrIgnore` | `TSqlMapInsertOrIgnore` (a `TSqlMapInsert`) | `TInsertOrIgnoreMappedStatement` (a `TInsertMappedStatement`) | +| `upsert` | `TSqlMapUpsert` (a `TSqlMapInsert`) | `TUpsertMappedStatement` (a `TInsertMappedStatement`) | + +--- + +[SqlMap Docs](../README.md) -> [Statements](README.md) -> insertOrIgnore and upsert diff --git a/src/Data/SqlMap/Configuration/TDiscriminator.php b/src/Data/SqlMap/Configuration/TDiscriminator.php index 62f6d10..53e39b6 100644 --- a/src/Data/SqlMap/Configuration/TDiscriminator.php +++ b/src/Data/SqlMap/Configuration/TDiscriminator.php @@ -1,7 +1,7 @@ * @link https://github.com/pradosoft/prado @@ -14,6 +14,8 @@ use Prado\Data\TSqlMapManager; /** + * TDiscriminator class + * * The TDiscriminator corresponds to the tag within a . * * TDiscriminator allows inheritance logic in SqlMap result mappings. @@ -25,6 +27,7 @@ * * @author Wei Zhuo * @since 3.1 + * @see TSubMap */ class TDiscriminator extends \Prado\TComponent { diff --git a/src/Data/SqlMap/Configuration/TParameterProperty.php b/src/Data/SqlMap/Configuration/TParameterProperty.php index 64d9575..c4190b1 100644 --- a/src/Data/SqlMap/Configuration/TParameterProperty.php +++ b/src/Data/SqlMap/Configuration/TParameterProperty.php @@ -133,9 +133,9 @@ public function setNullValue($value) $this->_nullValue = $value; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if ($this->_typeHandler === null) { $exprops[] = "\0$cn\0_typeHandler"; @@ -155,6 +155,5 @@ public function __sleep() if ($this->_nullValue === null) { $exprops[] = "\0$cn\0_nullValue"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/src/Data/SqlMap/Configuration/TResultProperty.php b/src/Data/SqlMap/Configuration/TResultProperty.php index 3cb690f..bc1274c 100644 --- a/src/Data/SqlMap/Configuration/TResultProperty.php +++ b/src/Data/SqlMap/Configuration/TResultProperty.php @@ -346,9 +346,9 @@ public function instanceOfArrayType($target) return $this->getPropertyValueType() == self::ARRAY_TYPE; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if ($this->_nullValue === null) { $exprops[] = "\0$cn\0_nullValue"; @@ -380,6 +380,5 @@ public function __sleep() if ($this->_select === null) { $exprops[] = "\0$cn\0_select"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/src/Data/SqlMap/Configuration/TSqlMapInsertOrIgnore.php b/src/Data/SqlMap/Configuration/TSqlMapInsertOrIgnore.php new file mode 100644 index 0000000..e51a548 --- /dev/null +++ b/src/Data/SqlMap/Configuration/TSqlMapInsertOrIgnore.php @@ -0,0 +1,24 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Configuration; + +/** + * TSqlMapInsertOrIgnore corresponds to the element. + * + * Behaves identically to TSqlMapInsert but executes via insertOrIgnore(), + * silently ignoring duplicate key conflicts. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TSqlMapInsertOrIgnore extends TSqlMapInsert +{ +} diff --git a/src/Data/SqlMap/Configuration/TSqlMapStatement.php b/src/Data/SqlMap/Configuration/TSqlMapStatement.php index fb36815..d62c4af 100644 --- a/src/Data/SqlMap/Configuration/TSqlMapStatement.php +++ b/src/Data/SqlMap/Configuration/TSqlMapStatement.php @@ -300,10 +300,11 @@ public function createInstanceOfResultClass($registry, $row) } } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; - $exprops = ["\0$cn\0_resultMap"]; + $exprops[] = "\0$cn\0_resultMap"; if (!$this->_parameterMapName) { $exprops[] = "\0$cn\0_parameterMapName"; } @@ -316,9 +317,6 @@ public function __sleep() if (!$this->_resultMapName) { $exprops[] = "\0$cn\0_resultMapName"; } - if (!$this->_resultMap) { - $exprops[] = "\0$cn\0_resultMap"; - } if (!$this->_resultClassName) { $exprops[] = "\0$cn\0_resultClassName"; } @@ -337,7 +335,5 @@ public function __sleep() if (!$this->_cache) { $exprops[] = "\0$cn\0_cache"; } - - return array_diff(parent::__sleep(), $exprops); } } diff --git a/src/Data/SqlMap/Configuration/TSqlMapUpsert.php b/src/Data/SqlMap/Configuration/TSqlMapUpsert.php new file mode 100644 index 0000000..63d6b4e --- /dev/null +++ b/src/Data/SqlMap/Configuration/TSqlMapUpsert.php @@ -0,0 +1,58 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Configuration; + +/** + * TSqlMapUpsert corresponds to the element. + * + * Supports optional updateColumns and conflictColumns attributes to control + * which columns are updated on conflict and which columns identify the conflict. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TSqlMapUpsert extends TSqlMapInsert +{ + private ?array $_updateColumns = null; + private ?array $_conflictColumns = null; + + /** + * @return null|array the columns to update on conflict, or null to use all non-PK columns. + */ + public function getUpdateColumns(): ?array + { + return $this->_updateColumns; + } + + /** + * @param array|string $value column names as comma-separated string or array. + */ + public function setUpdateColumns($value): void + { + $this->_updateColumns = is_string($value) ? array_map('trim', explode(',', $value)) : $value; + } + + /** + * @return null|array the conflict target columns, or null to use primary key columns. + */ + public function getConflictColumns(): ?array + { + return $this->_conflictColumns; + } + + /** + * @param array|string $value column names as comma-separated string or array. + */ + public function setConflictColumns($value): void + { + $this->_conflictColumns = is_string($value) ? array_map('trim', explode(',', $value)) : $value; + } +} diff --git a/src/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php b/src/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php index 4f20826..7c96c66 100644 --- a/src/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php +++ b/src/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php @@ -14,7 +14,7 @@ use Prado\Data\SqlMap\DataMapper\TSqlMapConfigurationException; /** - * TSqlMapXmlConfig class. + * TSqlMapXmlConfig class * * Configures the TSqlMapManager using xml configuration file. * diff --git a/src/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php b/src/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php index 3987b31..e67eb0c 100644 --- a/src/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php +++ b/src/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php @@ -1,7 +1,7 @@ * @link https://github.com/pradosoft/prado @@ -16,16 +16,62 @@ use Prado\Data\SqlMap\Statements\TCachingStatement; use Prado\Data\SqlMap\Statements\TDeleteMappedStatement; use Prado\Data\SqlMap\Statements\TInsertMappedStatement; +use Prado\Data\SqlMap\Statements\TInsertOrIgnoreMappedStatement; use Prado\Data\SqlMap\Statements\TMappedStatement; use Prado\Data\SqlMap\Statements\TSimpleDynamicSql; use Prado\Data\SqlMap\Statements\TStaticSql; use Prado\Data\SqlMap\Statements\TUpdateMappedStatement; +use Prado\Data\SqlMap\Statements\TUpsertMappedStatement; use Prado\Prado; /** - * Loads the statements, result maps, parameters maps from xml configuration. + * TSqlMapXmlMappingConfiguration class * - * description + * TSqlMapXmlMappingConfiguration loads statements, result maps, and parameter maps from XML mapping files. + * + * This builder parses XML mapping files and registers them with the SqlMap manager. + * It handles loading of resultMap, parameterMap, select, insert, insertOrIgnore, upsert, update, + * delete, statement, and cacheModel elements from the XML configuration. + * + * The XML mapping file follows the IBATIS SQL Map format: + * ```xml + * + * + * + * + * + * + * + * + * + * + * INSERT INTO users (username) VALUES (#username#) + * + * + * + * INSERT OR IGNORE INTO users (username) VALUES (#username#) + * + * + * + * INSERT INTO users (user_id, username) VALUES (#userId#, #username#) + * ON CONFLICT (user_id) DO UPDATE SET username = #username# + * + * + * + * UPDATE users SET username = #username# WHERE user_id = #userId# + * + * + * + * DELETE FROM users WHERE user_id = #id# + * + * + * ``` + * + * The configuration uses constants for parameter markers: + * - {@see SIMPLE_MARK} ($) for literal value substitution + * - {@see INLINE_SYMBOL} (#) for parameterized queries (used with parameter maps) * * @author Wei Zhuo * @since 3.1 @@ -59,13 +105,28 @@ public function __construct(TSqlMapXmlConfiguration $xmlConfig) $this->_manager = $xmlConfig->getManager(); } + /** + * @return string the configured XML mapping file path. + */ protected function getConfigFile() { return $this->_configFile; } /** - * Configure an XML mapping. + * Configures the XML mapping by loading and parsing the XML file. + * + * This method loads the XML mapping file and registers all defined elements + * with the SqlMap manager: + * - resultMap elements for mapping query results to objects + * - parameterMap elements for mapping input parameters + * - select, insert, insertOrIgnore, upsert, update, delete statements + * - statement elements for generic statements + * - procedure elements (not yet implemented) + * - cacheModel elements for query caching + * + * Cache dependencies are automatically registered for cache invalidation. + * * @param string $filename xml mapping filename. */ public function configure($filename) @@ -105,6 +166,14 @@ public function configure($filename) $this->loadInsertTag($node); } + foreach ($document->xpath('//insertOrIgnore') as $node) { + $this->loadInsertOrIgnoreTag($node); + } + + foreach ($document->xpath('//upsert') as $node) { + $this->loadUpsertTag($node); + } + foreach ($document->xpath('//update') as $node) { $this->loadUpdateTag($node); } @@ -125,7 +194,8 @@ public function configure($filename) } /** - * Load the result maps. + * Loads the result map from XML node. + * Handles inheritance from parent resultMaps if specified. * @param \SimpleXmlElement $node result map node. */ protected function loadResultMap($node) @@ -209,8 +279,8 @@ protected function createResultMap($node) } /** - * Load parameter map from xml. - * + * Loads parameter map from XML node. + * Handles inheritance from parent parameterMaps if specified. * @param \SimpleXmlElement $node parameter map node. */ protected function loadParameterMap($node) @@ -260,7 +330,8 @@ protected function createParameterMap($node) } /** - * Load statement mapping from xml configuration file. + * Loads generic statement mapping from XML configuration file. + * Processes SQL text, handles inheritance, applies inline parameters. * @param \SimpleXmlElement $node statement node. */ protected function loadStatementTag($node) @@ -356,7 +427,8 @@ protected function prepareSql($statement, $sqlStatement, $node) } /** - * Load select statement from xml mapping. + * Loads select statement from XML mapping. + * Supports optional cacheModel for query caching. * @param \SimpleXmlElement $node select node. */ protected function loadSelectTag($node) @@ -374,7 +446,8 @@ protected function loadSelectTag($node) } /** - * Load insert statement from xml mapping. + * Loads insert statement from XML mapping. + * Supports selectKey for auto-increment value retrieval. * @param \SimpleXmlElement $node insert node. */ protected function loadInsertTag($node) @@ -385,6 +458,32 @@ protected function loadInsertTag($node) $this->_manager->addMappedStatement($mappedStatement); } + /** + * Load insertOrIgnore statement from xml mapping. + * @param \SimpleXmlElement $node insertOrIgnore node. + * @since 4.4.0 + */ + protected function loadInsertOrIgnoreTag($node) + { + $insert = $this->createInsertOrIgnoreStatement($node); + $this->processSqlStatement($insert, $node); + $mappedStatement = new TInsertOrIgnoreMappedStatement($this->_manager, $insert); + $this->_manager->addMappedStatement($mappedStatement); + } + + /** + * Load upsert statement from xml mapping. + * @param \SimpleXmlElement $node upsert node. + * @since 4.4.0 + */ + protected function loadUpsertTag($node) + { + $insert = $this->createUpsertStatement($node); + $this->processSqlStatement($insert, $node); + $mappedStatement = new TUpsertMappedStatement($this->_manager, $insert); + $this->_manager->addMappedStatement($mappedStatement); + } + /** * Create new insert statement from xml node. * @param \SimpleXmlElement $node insert node. @@ -400,6 +499,38 @@ protected function createInsertStatement($node) return $insert; } + /** + * Create new insertOrIgnore statement from xml node. + * @param \SimpleXmlElement $node insertOrIgnore node. + * @return TSqlMapInsertOrIgnore insertOrIgnore statement. + * @since 4.4.0 + */ + protected function createInsertOrIgnoreStatement($node) + { + $insert = new TSqlMapInsertOrIgnore(); + $this->setObjectPropFromNode($insert, $node); + if (isset($node->selectKey)) { + $this->loadSelectKeyTag($insert, $node->selectKey); + } + return $insert; + } + + /** + * Create new upsert statement from xml node. + * @param \SimpleXmlElement $node upsert node. + * @return TSqlMapUpsert upsert statement. + * @since 4.4.0 + */ + protected function createUpsertStatement($node) + { + $insert = new TSqlMapUpsert(); + $this->setObjectPropFromNode($insert, $node); + if (isset($node->selectKey)) { + $this->loadSelectKeyTag($insert, $node->selectKey); + } + return $insert; + } + /** * Load the selectKey statement from xml mapping. * @param mixed $insert @@ -431,7 +562,7 @@ protected function loadUpdateTag($node) } /** - * Load delete statement from xml mapping. + * Loads delete statement from XML mapping. * @param \SimpleXmlElement $node delete node. */ protected function loadDeleteTag($node) @@ -444,7 +575,7 @@ protected function loadDeleteTag($node) } /** - * Load procedure statement from xml mapping. + * Loads procedure statement from XML mapping. * @todo Implement loading procedure * @param \SimpleXmlElement $node procedure node */ diff --git a/src/Data/SqlMap/Configuration/TSubMap.php b/src/Data/SqlMap/Configuration/TSubMap.php index d551797..2923ebb 100644 --- a/src/Data/SqlMap/Configuration/TSubMap.php +++ b/src/Data/SqlMap/Configuration/TSubMap.php @@ -11,6 +11,8 @@ namespace Prado\Data\SqlMap\Configuration; /** + * TSubMap class + * * TSubMap class defines a submapping value and the corresponding * * The {@see Value setValue()} property is used for comparison with the @@ -20,6 +22,7 @@ * * @author Wei Zhuo * @since 3.1 + * @see TDiscriminator */ class TSubMap extends \Prado\TComponent { diff --git a/src/Data/SqlMap/DataMapper/messages.txt b/src/Data/SqlMap/DataMapper/messages.txt index 92532a5..68b54a8 100644 --- a/src/Data/SqlMap/DataMapper/messages.txt +++ b/src/Data/SqlMap/DataMapper/messages.txt @@ -69,3 +69,4 @@ sqlmap_configfile_invalid = SQLMap configuration file or namespace ''{0}'' is sqlmap_must_enable_custom_paging = TSqlMapPagedList always uses custom paging; CustomPaging cannot be changed. sqlmap_property_must_be_list = A grouped result property must be an array or TList to hold child objects. sqlmap_use_set_to_store_cache = Use set() to store items in the SQLMap cache; add() is not supported. +sqlmap_limit_offset_unsupported = Command builder ''{0}'' cannot apply a limit or offset; SqlMap needs an SQL command builder. diff --git a/src/Data/SqlMap/Statements/IMappedStatement.php b/src/Data/SqlMap/Statements/IMappedStatement.php index 4762fb7..5edde9e 100644 --- a/src/Data/SqlMap/Statements/IMappedStatement.php +++ b/src/Data/SqlMap/Statements/IMappedStatement.php @@ -8,7 +8,11 @@ namespace Prado\Data\SqlMap\Statements; +use Prado\Data\IDataConnection; + /** + * IMappedStatement interface + * * Interface for all mapping statements. * * @author Wei Zhuo @@ -37,7 +41,7 @@ public function getManager(); * each key will be the value of the property specified in the * $valueProperty parameter. If $valueProperty is * null, the entire result object will be entered. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @param string $keyProperty The property of the result object to be used as the key. * @param string $valueProperty The property of the result object to be used as the value (or null) @@ -51,7 +55,7 @@ public function executeQueryForMap($connection, $parameter, $keyProperty, $value /** * Execute an update statement. Also used for delete statement. Return the * number of row effected. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @return int The number of row effected. */ @@ -60,7 +64,7 @@ public function executeUpdate($connection, $parameter); /** * Executes the SQL and retuns a subset of the rows selected. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @param null|\Prado\Collections\TList $result A list to populate the result with. * @param int $skip The number of rows to skip over. @@ -73,7 +77,7 @@ public function executeQueryForList($connection, $parameter, $result = null, $sk /** * Executes an SQL statement that returns a single row as an object * of the type of the $result passed in as a parameter. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @param object $result The result object. * @return object result. @@ -83,7 +87,7 @@ public function executeQueryForObject($connection, $parameter, $result = null); /** * Execute an insert statement. Fill the parameter object with the ouput * parameters if any, also could return the insert generated key. - * @param \Prado\Data\TDbConnection $connection database connection + * @param \Prado\Data\IDataConnection $connection database connection * @param mixed $parameter The parameter object used to fill the statement. * @return string the insert generated key. */ diff --git a/src/Data/SqlMap/Statements/TInsertOrIgnoreMappedStatement.php b/src/Data/SqlMap/Statements/TInsertOrIgnoreMappedStatement.php new file mode 100644 index 0000000..e5779da --- /dev/null +++ b/src/Data/SqlMap/Statements/TInsertOrIgnoreMappedStatement.php @@ -0,0 +1,26 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Statements; + +/** + * TInsertOrIgnoreMappedStatement executes insertOrIgnore mapped statements. + * + * Corresponds to the SqlMap XML element. Behaves identically to + * TInsertMappedStatement but signals to the framework that this is an + * insert-or-ignore operation. The driver-specific SQL (e.g. INSERT IGNORE INTO + * or INSERT OR IGNORE INTO) is written directly in the XML mapping. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TInsertOrIgnoreMappedStatement extends TInsertMappedStatement +{ +} diff --git a/src/Data/SqlMap/Statements/TMappedStatement.php b/src/Data/SqlMap/Statements/TMappedStatement.php index 69236f6..9671a53 100644 --- a/src/Data/SqlMap/Statements/TMappedStatement.php +++ b/src/Data/SqlMap/Statements/TMappedStatement.php @@ -27,7 +27,7 @@ * TMappedStatement class executes SQL mapped statements. Mapped Statements can * hold any SQL statement and use Parameter Maps and Result Maps for input and output. * - * This class is usualy instantiated during SQLMap configuration by TSqlDomBuilder. + * This class is usually instantiated during SQLMap configuration by TSqlDomBuilder. * * @author Wei Zhuo * @since 3.0 @@ -89,7 +89,7 @@ public function getID() } /** - * @return TSqlMapStatement The SQL statment used by this MappedStatement + * @return TSqlMapStatement The SQL statement used by this MappedStatement */ public function getStatement() { @@ -160,7 +160,7 @@ protected function executeSQLQueryLimit($connection, $command, $max, $skip) } /** - * Executes the SQL and retuns a List of result objects. + * Executes the SQL and returns a List of result objects. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter The object used to set the parameters in the SQL. * @param null|object $result result collection object. @@ -177,7 +177,7 @@ public function executeQueryForList($connection, $parameter, $result = null, $sk } /** - * Executes the SQL and retuns a List of result objects. + * Executes the SQL and returns a List of result objects. * * This method should only be called by internal developers, consider using * executeQueryForList() first. @@ -221,14 +221,14 @@ public function runQueryForList($connection, $parameter, $sql, $result, $delegat } /** - * Executes the SQL and retuns all rows selected in a map that is keyed on + * Executes the SQL and returns all rows selected in a map that is keyed on * the property named in the keyProperty parameter. The value at each key * will be the value of the property specified in the valueProperty parameter. * If valueProperty is null, the entire result object will be entered. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter The object used to set the parameters in the SQL. * @param string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value (or null). + * @param ?string $valueProperty The property of the result object to be used as the value (or null). * @param int $skip The number of rows to skip over. * @param int $max The maximum number of rows to return. * @param null|callable $delegate row delegate handler @@ -241,7 +241,7 @@ public function executeQueryForMap($connection, $parameter, $keyProperty, $value } /** - * Executes the SQL and retuns all rows selected in a map that is keyed on + * Executes the SQL and returns all rows selected in a map that is keyed on * the property named in the keyProperty parameter. The value at each key * will be the value of the property specified in the valueProperty parameter. * If valueProperty is null, the entire result object will be entered. @@ -253,8 +253,8 @@ public function executeQueryForMap($connection, $parameter, $keyProperty, $value * @param mixed $parameter The object used to set the parameters in the SQL. * @param mixed $command * @param string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value (or null). - * @param null|callable $delegate row delegate, a callback function + * @param ?string $valueProperty The property of the result object to be used as the value (or null). + * @param ?callable $delegate row delegate, a callback function * @return array An array of object containing the rows keyed by keyProperty. * @see executeQueryForMap() */ @@ -360,7 +360,7 @@ public function runQueryForObject($connection, $command, &$result) } /** - * Execute an insert statement. Fill the parameter object with the ouput + * Execute an insert statement. Fill the parameter object with the output * parameters if any, also could return the insert generated key. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter The parameter object used to fill the statement. @@ -386,7 +386,7 @@ public function executeInsert($connection, $parameter) * Gets the insert generated ID before executing an insert statement. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter insert statement parameter. - * @return null|string new insert ID if pre-select key statement was executed, null otherwise. + * @return ?string new insert ID if pre-select key statement was executed, null otherwise. */ protected function getPreGeneratedSelectKey($connection, $parameter) { @@ -403,7 +403,7 @@ protected function getPreGeneratedSelectKey($connection, $parameter) * Gets the inserted row ID after executing an insert statement. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter insert statement parameter. - * @return null|string last insert ID, null otherwise. + * @return ?string last insert ID, null otherwise. */ protected function getPostGeneratedSelectKey($connection, $parameter) { @@ -483,7 +483,7 @@ protected function executePostSelect($connection) /** * Raise the execute query event. - * @param array $sql prepared SQL statement and subsititution parameters + * @param array $sql prepared SQL statement and substitution parameters */ public function onExecuteQuery($sql) { @@ -753,10 +753,10 @@ protected function hasResultMapRowData($resultMap, $row) } /** - * Gets the result 'group by' groupping key for each row. + * Gets the result 'group by' grouping key for each row. * @param TResultMap $resultMap result mapping details. * @param array $row a result set row retrieved from the database - * @return string groupping key. + * @return string grouping key. */ protected function getResultMapGroupKey($resultMap, $row) { @@ -953,6 +953,7 @@ protected function getPostSelectKeys($resultMap, $property, $row) if (array_key_exists($colName, $row)) { $keys[trim($pair[0])] = $row[$colName]; } else { + // Case-insensitive fallback. $keys[trim($pair[0])] = $rowLower[strtolower($colName)] ?? null; } } @@ -992,9 +993,9 @@ public function __wakeup() parent::__wakeup(); } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if (!count($this->_selectQueue)) { $exprops[] = "\0$cn\0_selectQueue"; @@ -1005,6 +1006,5 @@ public function __sleep() if (!$this->_IsRowDataFound) { $exprops[] = "\0$cn\0_IsRowDataFound"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/src/Data/SqlMap/Statements/TPreparedCommand.php b/src/Data/SqlMap/Statements/TPreparedCommand.php index af6db79..27a6673 100644 --- a/src/Data/SqlMap/Statements/TPreparedCommand.php +++ b/src/Data/SqlMap/Statements/TPreparedCommand.php @@ -10,8 +10,10 @@ namespace Prado\Data\SqlMap\Statements; +use Prado\Data\Common\IDbCommandBuilder; use Prado\Data\Common\TDbCommandBuilder; use Prado\Data\Common\TDbMetaData; +use Prado\Data\SqlMap\DataMapper\TSqlMapExecutionException; use Prado\Data\SqlMap\TSqlMapManager; use Prado\Prado; @@ -37,6 +39,9 @@ public function create(TSqlMapManager $manager, $connection, $statement, $parame if ($max !== null || $skip !== null) { $builder = TDbMetaData::getInstance($connection)->createCommandBuilder(); + if (!($builder instanceof IDbCommandBuilder || $builder instanceof TDbCommandBuilder)) { + throw new TSqlMapExecutionException('sqlmap_limit_offset_unsupported', $builder::class); + } $sql = $builder->applyLimitOffset($sql, $max, $skip); } $command = $connection->createCommand($sql); @@ -61,12 +66,12 @@ protected function applyParameterMap($manager, $command, $prepared, $statement, } $dbType = $property->getDbType(); if ($dbType == '') { //relies on PHP lax comparison - $pdoType = TDbCommandBuilder::getPdoType($value); - if ($pdoType === \PDO::PARAM_NULL && $driverName === 'ibm') { - // pdo_ibm rejects PDO::PARAM_NULL; it binds a null PDO::PARAM_STR as SQL NULL. - $pdoType = \PDO::PARAM_STR; + $pdoType = $this->getParameterType($command, $value, $driverName); + if ($pdoType !== null) { + $command->bindValue($i + 1, $value, $pdoType); + } else { + $command->bindValue($i + 1, $value); } - $command->bindValue($i + 1, $value, $pdoType); } elseif (strpos($dbType, 'PDO::') === 0) { $command->bindValue($i + 1, $value, constant($property->getDbType())); } //assumes PDO types, e.g. PDO::PARAM_INT @@ -76,4 +81,27 @@ protected function applyParameterMap($manager, $command, $prepared, $statement, } } } + + /** + * Returns the PDO type to bind a parameter value with. + * PRADO 4.4's `TDbCommand::getColumnTypeFromValue()` decides when the command provides it. + * Otherwise the type follows the value, and a null binds as a string on `ibm`, + * because pdo_ibm rejects PDO::PARAM_NULL and binds a null string as SQL NULL. + * @param \Prado\Data\TDbCommand $command the command the value binds to. + * @param mixed $value the parameter value. + * @param string $driverName the PDO driver name of the command's connection. + * @return ?int the PDO::PARAM_* type, or null to bind without a type. + * @since 4.4.0 + */ + protected function getParameterType($command, $value, $driverName) + { + if (method_exists($command, 'getColumnTypeFromValue')) { + return $command->getColumnTypeFromValue($value); + } + $pdoType = TDbCommandBuilder::getPdoType($value); + if ($pdoType === \PDO::PARAM_NULL && $driverName === 'ibm') { + $pdoType = \PDO::PARAM_STR; + } + return $pdoType; + } } diff --git a/src/Data/SqlMap/Statements/TPreparedStatement.php b/src/Data/SqlMap/Statements/TPreparedStatement.php index 78febcc..3e33872 100644 --- a/src/Data/SqlMap/Statements/TPreparedStatement.php +++ b/src/Data/SqlMap/Statements/TPreparedStatement.php @@ -60,9 +60,9 @@ public function setParameterValues($value) $this->_parameterValues = $value; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if (!$this->_parameterNames || !$this->_parameterNames->getCount()) { $exprops[] = "\0$cn\0_parameterNames"; @@ -70,6 +70,5 @@ public function __sleep() if (!$this->_parameterValues || !$this->_parameterValues->getCount()) { $exprops[] = "\0$cn\0_parameterValues"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/src/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php b/src/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php index d3dacb5..0b40d7f 100644 --- a/src/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php +++ b/src/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php @@ -195,9 +195,9 @@ protected function getCollection() return $this->_list; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if (!count($this->_tree)) { $exprops[] = "\0$cn\0_tree"; @@ -208,6 +208,5 @@ public function __sleep() if (!count($this->_list)) { $exprops[] = "\0$cn\0_list"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/src/Data/SqlMap/Statements/TUpsertMappedStatement.php b/src/Data/SqlMap/Statements/TUpsertMappedStatement.php new file mode 100644 index 0000000..cc7dcaf --- /dev/null +++ b/src/Data/SqlMap/Statements/TUpsertMappedStatement.php @@ -0,0 +1,28 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Statements; + +/** + * TUpsertMappedStatement executes upsert mapped statements. + * + * Corresponds to the SqlMap XML element. Behaves identically to + * TInsertMappedStatement but signals to the framework that this is an + * insert-or-update (upsert) operation. The driver-specific SQL (e.g. + * INSERT ... ON DUPLICATE KEY UPDATE or INSERT ... ON CONFLICT ... DO UPDATE SET) + * is written directly in the XML mapping. Optional updateColumns and conflictColumns + * attributes in the element are stored on the TSqlMapUpsert configuration object. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TUpsertMappedStatement extends TInsertMappedStatement +{ +} diff --git a/src/Data/SqlMap/TSqlMapConfig.php b/src/Data/SqlMap/TSqlMapConfig.php index ccd813b..6788a55 100644 --- a/src/Data/SqlMap/TSqlMapConfig.php +++ b/src/Data/SqlMap/TSqlMapConfig.php @@ -85,6 +85,7 @@ * * @author Wei Zhuo * @since 3.1 + * see https://github.com/mybatis/ */ class TSqlMapConfig extends TDataSourceConfig { diff --git a/src/Data/SqlMap/TSqlMapGateway.php b/src/Data/SqlMap/TSqlMapGateway.php index 9751f66..95d064c 100644 --- a/src/Data/SqlMap/TSqlMapGateway.php +++ b/src/Data/SqlMap/TSqlMapGateway.php @@ -17,7 +17,9 @@ use Prado\Prado; /** - * DataMapper client, a fascade to provide access the rest of the DataMapper + * TSqlMapGateway class + * + * DataMapper client, a façade to provide access the rest of the DataMapper * framework. It provides three core functions: * * # execute an update query (including insert and delete). @@ -28,6 +30,7 @@ * * @author Wei Zhuo * @since 3.1 + * see https://github.com/mybatis/ */ class TSqlMapGateway extends \Prado\TComponent { @@ -164,8 +167,8 @@ public function queryForPagedListWithRowDelegate($statementName, $delegate, $par * entered. * @param string $statementName The name of the sql statement to execute. * @param null|mixed $parameter The object used to set the parameters in the SQL. - * @param null|string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value. + * @param ?string $keyProperty The property of the result object to be used as the key. + * @param ?string $valueProperty The property of the result object to be used as the value. * @param int $skip The number of rows to skip over. * @param int $max The maximum number of rows to return. * @return array Array object containing the rows keyed by keyProperty. @@ -185,8 +188,8 @@ public function queryForMap($statementName, $parameter = null, $keyProperty = nu * @param string $statementName The name of the sql statement to execute. * @param callable $delegate Row delegate handler, a valid callback required. * @param null|mixed $parameter The object used to set the parameters in the SQL. - * @param null|string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value. + * @param ?string $keyProperty The property of the result object to be used as the key. + * @param ?string $valueProperty The property of the result object to be used as the value. * @param int $skip The number of rows to skip over. * @param int $max The maximum number of rows to return. * @return array Array object containing the rows keyed by keyProperty. @@ -208,7 +211,7 @@ public function queryForMapWithRowDelegate($statementName, $delegate, $parameter * INSERT values. * * @param string $statementName The name of the statement to execute. - * @param null|string $parameter The parameter object. + * @param ?string $parameter The parameter object. * @return mixed The primary key of the newly inserted row. * This might be automatically generated by the RDBMS, * or selected from a sequence table or other source. diff --git a/src/Data/SqlMap/TSqlMapManager.php b/src/Data/SqlMap/TSqlMapManager.php index 911e12b..2cb3f17 100644 --- a/src/Data/SqlMap/TSqlMapManager.php +++ b/src/Data/SqlMap/TSqlMapManager.php @@ -24,6 +24,8 @@ use Prado\Prado; /** + * TSqlMapManager class + * * TSqlMapManager class holds the sqlmap configuation result maps, statements * parameter maps and a type handler factory. * @@ -44,6 +46,7 @@ * * @author Wei Zhuo * @since 3.1 + * see https://github.com/mybatis/ */ class TSqlMapManager extends \Prado\TComponent { diff --git a/tests/unit/Data/SqlMap/Configuration/TSqlMapUpsertTest.php b/tests/unit/Data/SqlMap/Configuration/TSqlMapUpsertTest.php new file mode 100644 index 0000000..3f0d397 --- /dev/null +++ b/tests/unit/Data/SqlMap/Configuration/TSqlMapUpsertTest.php @@ -0,0 +1,86 @@ +` and `` mapping elements without a database. + */ +class TSqlMapUpsertTest extends PHPUnit\Framework\TestCase +{ + private TSqlMapManager $manager; + + protected function setUp(): void + { + $this->manager = new TSqlMapManager(); + $this->manager->configureXml(__DIR__ . '/../maps/upsert-config.xml'); + } + + public function testInsertOrIgnoreElementCreatesItsStatement() + { + $mapped = $this->manager->getMappedStatement('InsertOrIgnoreItem'); + $this->assertInstanceOf(TInsertOrIgnoreMappedStatement::class, $mapped); + $this->assertInstanceOf(TInsertMappedStatement::class, $mapped); + $this->assertInstanceOf(TSqlMapInsertOrIgnore::class, $mapped->getStatement()); + $this->assertInstanceOf(TSqlMapInsert::class, $mapped->getStatement()); + } + + public function testUpsertElementCreatesItsStatement() + { + $mapped = $this->manager->getMappedStatement('UpsertItem'); + $this->assertInstanceOf(TUpsertMappedStatement::class, $mapped); + $this->assertInstanceOf(TInsertMappedStatement::class, $mapped); + $this->assertInstanceOf(TSqlMapUpsert::class, $mapped->getStatement()); + } + + public function testUpsertAttributesAreColumnLists() + { + $statement = $this->manager->getMappedStatement('UpsertItem')->getStatement(); + $this->assertSame(['score', 'note'], $statement->getUpdateColumns()); + $this->assertSame(['name'], $statement->getConflictColumns()); + } + + public function testUpsertAttributesDefaultToNull() + { + $statement = $this->manager->getMappedStatement('UpsertDefaults')->getStatement(); + $this->assertNull($statement->getUpdateColumns()); + $this->assertNull($statement->getConflictColumns()); + } + + public function testUpsertColumnListsAcceptArrays() + { + $statement = new TSqlMapUpsert(); + $statement->setUpdateColumns(['a', 'b']); + $statement->setConflictColumns(['c']); + $this->assertSame(['a', 'b'], $statement->getUpdateColumns()); + $this->assertSame(['c'], $statement->getConflictColumns()); + } + + public function testUpsertSelectKeyIsRegistered() + { + $statement = $this->manager->getMappedStatement('UpsertWithKey')->getStatement(); + $this->assertSame('UpsertWithKey.SelectKey', $statement->getSelectKey()->getID()); + $this->assertNotNull($this->manager->getMappedStatement('UpsertWithKey.SelectKey')); + } + + /** + * @dataProvider writeStatements + */ + public function testWriteStatementFlushesItsCacheModel(string $statementID) + { + $cache = $this->manager->getCacheModel('upsert-cache'); + $cache->set('key', 'value'); + $this->manager->getMappedStatement($statementID)->onExecuteQuery(''); + $this->assertNull($cache->get('key')); + } + + public static function writeStatements(): array + { + return [['InsertOrIgnoreItem'], ['UpsertItem']]; + } +} diff --git a/tests/unit/Data/SqlMap/DbSpecific/sqlite/SqliteUpsertStatementTest.php b/tests/unit/Data/SqlMap/DbSpecific/sqlite/SqliteUpsertStatementTest.php new file mode 100644 index 0000000..544ce28 --- /dev/null +++ b/tests/unit/Data/SqlMap/DbSpecific/sqlite/SqliteUpsertStatementTest.php @@ -0,0 +1,82 @@ +` and `` statements through TSqlMapGateway::insert() + * on an in-memory SQLite database. + */ +class SqliteUpsertStatementTest extends PHPUnit\Framework\TestCase +{ + private TDbConnection $connection; + private \Prado\Data\SqlMap\TSqlMapGateway $gateway; + + protected function setUp(): void + { + if (!extension_loaded('pdo_sqlite')) { + $this->markTestSkipped('pdo_sqlite is not available.'); + } + $this->connection = new TDbConnection('sqlite::memory:'); + $this->connection->setActive(true); + $this->connection->createCommand( + 'CREATE TABLE upsert_items (item_id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(20) NOT NULL UNIQUE, score INTEGER NOT NULL DEFAULT 0)' + )->execute(); + $manager = new TSqlMapManager($this->connection); + $manager->configureXml(__DIR__ . '/../../maps/upsert-config.xml'); + $this->gateway = $manager->getSqlmapGateway(); + } + + protected function tearDown(): void + { + $this->connection->setActive(false); + } + + private function score(string $name): ?int + { + $row = $this->gateway->queryForObject('SelectItem', $name); + return $row === null ? null : (int) $row['score']; + } + + private function rowCount(): int + { + return (int) $this->connection->createCommand('SELECT COUNT(*) FROM upsert_items')->queryScalar(); + } + + public function testInsertOrIgnoreInsertsANewRow() + { + $this->gateway->insert('InsertOrIgnoreItem', ['name' => 'alice', 'score' => 10]); + $this->assertSame(10, $this->score('alice')); + } + + public function testInsertOrIgnoreLeavesAConflictingRow() + { + $this->gateway->insert('InsertOrIgnoreItem', ['name' => 'alice', 'score' => 10]); + $this->gateway->insert('InsertOrIgnoreItem', ['name' => 'alice', 'score' => 99]); + $this->assertSame(10, $this->score('alice')); + $this->assertSame(1, $this->rowCount()); + } + + public function testUpsertInsertsANewRow() + { + $this->gateway->insert('UpsertItem', ['name' => 'bob', 'score' => 5]); + $this->assertSame(5, $this->score('bob')); + } + + public function testUpsertUpdatesAConflictingRow() + { + $this->gateway->insert('UpsertItem', ['name' => 'bob', 'score' => 5]); + $this->gateway->insert('UpsertItem', ['name' => 'bob', 'score' => 42]); + $this->assertSame(42, $this->score('bob')); + $this->assertSame(1, $this->rowCount()); + } + + public function testUpsertSelectKeyReturnsTheKeyOnInsertAndUpdate() + { + $first = $this->gateway->insert('UpsertWithKey', ['name' => 'carol', 'score' => 1]); + $second = $this->gateway->insert('UpsertWithKey', ['name' => 'carol', 'score' => 2]); + $this->assertGreaterThan(0, (int) $first); + $this->assertSame((int) $first, (int) $second); + $this->assertSame(2, $this->score('carol')); + } +} diff --git a/tests/unit/Data/SqlMap/maps/upsert-config.xml b/tests/unit/Data/SqlMap/maps/upsert-config.xml new file mode 100644 index 0000000..3087fdd --- /dev/null +++ b/tests/unit/Data/SqlMap/maps/upsert-config.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/unit/Data/SqlMap/maps/upsert.xml b/tests/unit/Data/SqlMap/maps/upsert.xml new file mode 100644 index 0000000..b1dab3d --- /dev/null +++ b/tests/unit/Data/SqlMap/maps/upsert.xml @@ -0,0 +1,30 @@ + + + + + + + + INSERT OR IGNORE INTO upsert_items (name, score) VALUES (#name#, #score#) + + + + INSERT INTO upsert_items (name, score) VALUES (#name#, #score#) + ON CONFLICT (name) DO UPDATE SET score = excluded.score + + + + INSERT INTO upsert_items (name, score) VALUES (#name#, #score#) + ON CONFLICT (name) DO NOTHING + + + + INSERT INTO upsert_items (name, score) VALUES (#name#, #score#) + ON CONFLICT (name) DO UPDATE SET score = excluded.score + + SELECT item_id FROM upsert_items WHERE name = #name# + + + From d6f7fe217086d1d79f74991e7b48807c62cd45e3 Mon Sep 17 00:00:00 2001 From: Belisoful Date: Thu, 5 Nov 2026 03:50:48 -0800 Subject: [PATCH 2/3] CI: match the SQL Server job's Microsoft repo to the runner's Ubuntu release --- .github/scripts/setup-sqlsrv.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/scripts/setup-sqlsrv.sh b/.github/scripts/setup-sqlsrv.sh index 4b941b5..f2eb481 100755 --- a/.github/scripts/setup-sqlsrv.sh +++ b/.github/scripts/setup-sqlsrv.sh @@ -3,8 +3,13 @@ set -euo pipefail SQLCMD=/opt/mssql-tools18/bin/sqlcmd -curl -sSL -O https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb -sudo dpkg -i packages-microsoft-prod.deb +# msodbcsql18 (pulled in by mssql-tools18) is the ODBC driver pdo_sqlsrv needs. +# The repo package must match the runner's Ubuntu release; ubuntu-latest +# rolls between releases. Newer images preinstall packages-microsoft-prod +# with its keyring deleted, so the conffile flags reinstall it without a prompt. +. /etc/os-release +curl -sSL -O "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" +sudo dpkg -i --force-confnew --force-confmiss packages-microsoft-prod.deb sudo apt-get update -qq sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 unixodbc-dev From a56bcb155acb0fb6243c2a3f4b6220e53bd8b243 Mon Sep 17 00:00:00 2001 From: Belisoful Date: Thu, 5 Nov 2026 04:05:43 -0800 Subject: [PATCH 3/3] Require PRADO 4.4; drop the fallbacks for older cores --- AGENTS.md | 2 +- CHANGELOG.md | 5 ++-- CLAUDE.md | 2 +- README.md | 4 +-- composer.json | 6 ++-- .../SqlMap/DataMapper/TSqlMapException.php | 18 ------------ .../SqlMap/Statements/TPreparedCommand.php | 29 ++----------------- 7 files changed, 13 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a94f220..1e397c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Run `composer fix` before `composer fulltest` to auto-apply style fixes; `fullte - Indentation: **tabs** (not spaces) - Line endings: Unix `\n` -- PHP 8.1+ syntax; CI tests 8.1, 8.2, 8.3 +- PHP 8.2+ syntax; CI tests 8.2, 8.3, 8.4, 8.5 - PSR-12 enforced via php-cs-fixer - Uniform Access / Self Encapsulation: access own fields only through getters/setters, never `$this->_prop` directly in subclasses diff --git a/CHANGELOG.md b/CHANGELOG.md index 976e8e2..2f7ab3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,12 @@ First standalone release, planned as 1.0.0, for PRADO 4.4. SqlMap moves out of t ### Changed -- A parameter without a `dbType` binds with the type from PRADO 4.4's `TDbCommand::getColumnTypeFromValue()` when the command provides it; otherwise as before. +- The package requires PRADO 4.4 (`"pradosoft/prado": "^4.4@dev"` until 4.4 is tagged) and therefore PHP 8.2. +- A parameter without a `dbType` binds with the type from `TDbCommand::getColumnTypeFromValue()`, which binds a null untyped on IBM DB2. - Paging through a connection whose command builder is not an SQL builder throws `TSqlMapExecutionException` `sqlmap_limit_offset_unsupported`. - The configuration and statement classes leave properties out of serialization through `_getZappableSleepProps()`, so `TComponent` also leaves out its default state. - `TSqlMapException::getErrorCode()` returns the message code (for example `sqlmap_contains_no_statement`). It previously returned the translated message text. -- `messages.txt` entries are ICU MessageFormat patterns (pradosoft/prado#1437): `'{0}'` is `''{0}''`. Messages render as before. Under PRADO 4.3, `TSqlMapException::translateErrorMessage()` returns `''` as `'`. +- `messages.txt` entries are ICU MessageFormat patterns (pradosoft/prado#1437): `'{0}'` is `''{0}''`. Messages render as before. - Active Record support is the `pradosoft/prado-activerecord` package (pradosoft/prado#1359). It is a suggested package and a development dependency for the Active Record SqlMap tests. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 368f3f0..8322b07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,7 +200,7 @@ The phpunit bootstrap (`tests/test_tools/phpunit_bootstrap.php`) loads `PradoUni - Indentation: **tabs** (not spaces) - Line endings: Unix (`\n`) -- PHP minimum: **8.1** (CI tests 8.2, 8.3, 8.4, 8.5; the `pradosoft/prado` dev dependency requires 8.2) +- PHP minimum: **8.2**, as PRADO 4.4 requires (CI tests 8.2, 8.3, 8.4, 8.5) - PSR-12 enforced via php-cs-fixer - Use `?` for single nullable types and in doc blocks diff --git a/README.md b/README.md index bf98add..8110a35 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ SqlMap maps SQL statements to PHP objects using XML configuration files. Queries ## Requirements -- PHP 8.1 or later -- PRADO 4.3.3 or later (`pradosoft/prado`) +- PHP 8.2 or later +- PRADO 4.4 or later (`pradosoft/prado`) - PDO extension and a PDO driver for your database ## Installation diff --git a/composer.json b/composer.json index bcd5bb6..8a9cee0 100644 --- a/composer.json +++ b/composer.json @@ -40,11 +40,11 @@ } ], "require": { - "php": ">=8.1.0", - "ext-pdo": "*" + "php": ">=8.2.0", + "ext-pdo": "*", + "pradosoft/prado": "^4.4@dev" }, "require-dev": { - "pradosoft/prado": "dev-master", "phpunit/phpunit": "^10", "phpdocumentor/shim": "^3", "friendsofphp/php-cs-fixer": "^3.94", diff --git a/src/Data/SqlMap/DataMapper/TSqlMapException.php b/src/Data/SqlMap/DataMapper/TSqlMapException.php index 23d2dd8..11f23a6 100644 --- a/src/Data/SqlMap/DataMapper/TSqlMapException.php +++ b/src/Data/SqlMap/DataMapper/TSqlMapException.php @@ -56,24 +56,6 @@ protected function implodeNode($node) return '<' . $node->getName() . ' ' . implode(' ', $attributes) . '>'; } - /** - * Returns the message of an error code. The message file holds ICU MessageFormat - * patterns, where an apostrophe beside a brace is doubled (`''{0}''`). - * - PRADO 4.4 formats the pattern with `\MessageFormatter`. - * - PRADO 4.3 substitutes `{n}` without ICU formatting, so `''` is returned as `'`. - * @param string $key the error code. - * @return string the message of the error code. - * @since 4.4.0 - */ - protected function translateErrorMessage($key) - { - $message = parent::translateErrorMessage($key); - if (!method_exists(TException::class, 'formatErrorMessage')) { - $message = str_replace("''", "'", $message); - } - return $message; - } - /** * @return string path to the error message file */ diff --git a/src/Data/SqlMap/Statements/TPreparedCommand.php b/src/Data/SqlMap/Statements/TPreparedCommand.php index 27a6673..4c66fd4 100644 --- a/src/Data/SqlMap/Statements/TPreparedCommand.php +++ b/src/Data/SqlMap/Statements/TPreparedCommand.php @@ -11,7 +11,6 @@ namespace Prado\Data\SqlMap\Statements; use Prado\Data\Common\IDbCommandBuilder; -use Prado\Data\Common\TDbCommandBuilder; use Prado\Data\Common\TDbMetaData; use Prado\Data\SqlMap\DataMapper\TSqlMapExecutionException; use Prado\Data\SqlMap\TSqlMapManager; @@ -39,7 +38,7 @@ public function create(TSqlMapManager $manager, $connection, $statement, $parame if ($max !== null || $skip !== null) { $builder = TDbMetaData::getInstance($connection)->createCommandBuilder(); - if (!($builder instanceof IDbCommandBuilder || $builder instanceof TDbCommandBuilder)) { + if (!$builder instanceof IDbCommandBuilder) { throw new TSqlMapExecutionException('sqlmap_limit_offset_unsupported', $builder::class); } $sql = $builder->applyLimitOffset($sql, $max, $skip); @@ -66,7 +65,8 @@ protected function applyParameterMap($manager, $command, $prepared, $statement, } $dbType = $property->getDbType(); if ($dbType == '') { //relies on PHP lax comparison - $pdoType = $this->getParameterType($command, $value, $driverName); + // null: the driver takes untyped parameters, as pdo_ibm does for a null. + $pdoType = $command->getColumnTypeFromValue($value); if ($pdoType !== null) { $command->bindValue($i + 1, $value, $pdoType); } else { @@ -81,27 +81,4 @@ protected function applyParameterMap($manager, $command, $prepared, $statement, } } } - - /** - * Returns the PDO type to bind a parameter value with. - * PRADO 4.4's `TDbCommand::getColumnTypeFromValue()` decides when the command provides it. - * Otherwise the type follows the value, and a null binds as a string on `ibm`, - * because pdo_ibm rejects PDO::PARAM_NULL and binds a null string as SQL NULL. - * @param \Prado\Data\TDbCommand $command the command the value binds to. - * @param mixed $value the parameter value. - * @param string $driverName the PDO driver name of the command's connection. - * @return ?int the PDO::PARAM_* type, or null to bind without a type. - * @since 4.4.0 - */ - protected function getParameterType($command, $value, $driverName) - { - if (method_exists($command, 'getColumnTypeFromValue')) { - return $command->getColumnTypeFromValue($value); - } - $pdoType = TDbCommandBuilder::getPdoType($value); - if ($pdoType === \PDO::PARAM_NULL && $driverName === 'ibm') { - $pdoType = \PDO::PARAM_STR; - } - return $pdoType; - } }