diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 006fa76638..7bd184b02b 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -628,5 +628,55 @@ component output=false extends="wheels.Global"{ ); } + /** + * Generates database-specific UPSERT SQL as an array compatible with `$querySetup()`. + * Base implementation throws an error — each adapter must override with its own syntax. + * + * @tableName The quoted table name. + * @columns Array of column names to insert/update. + * @uniqueBy Array of column names forming the unique constraint. + * @updateColumns Array of column names to update on conflict. + * @validProperties Array of model property names corresponding to `columns`. + * @records Array of record structs. + * @batchStart Starting index in the records array. + * @batchEnd Ending index in the records array. + * @propertyInfo Struct of model property metadata. + */ + public array function $upsertSQL( + required string tableName, + required array columns, + required array uniqueBy, + required array updateColumns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd, + required struct propertyInfo + ) { + Throw( + type = "Wheels.UpsertNotSupported", + message = "Upsert is not supported by this database adapter.", + extendedInfo = "Override `$upsertSQL()` in the specific database adapter to enable upsert support." + ); + } + + /** + * Builds parameter struct for a single value in a bulk operation. + * Used by adapter upsert implementations. + */ + public struct function $buildBulkParam( + required string value, + required string propName, + required struct propertyInfo + ) { + local.propInfo = arguments.propertyInfo[arguments.propName]; + return { + value: arguments.value, + type: local.propInfo.type, + dataType: local.propInfo.dataType, + scale: local.propInfo.scale, + null: (!Len(arguments.value) && local.propInfo.nullable) + }; + } } diff --git a/vendor/wheels/databaseAdapters/H2/H2Model.cfc b/vendor/wheels/databaseAdapters/H2/H2Model.cfc index ea4b169e13..cd987e5b19 100755 --- a/vendor/wheels/databaseAdapters/H2/H2Model.cfc +++ b/vendor/wheels/databaseAdapters/H2/H2Model.cfc @@ -180,5 +180,55 @@ component extends="wheels.databaseAdapters.Base" output=false { return local.columns; } + /** + * H2 upsert using single MERGE INTO with multi-row VALUES. + * H2 syntax: MERGE INTO t (cols) KEY (uniqueBy) VALUES (row1), (row2), ... + */ + public array function $upsertSQL( + required string tableName, + required array columns, + required array uniqueBy, + required array updateColumns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd, + required struct propertyInfo + ) { + local.sql = []; + + // Build column list. + local.colList = ""; + for (local.col in arguments.columns) { + if (Len(local.colList)) local.colList &= ", "; + local.colList &= $quoteIdentifier(local.col); + } + + // Build KEY clause. + local.keyList = ""; + for (local.u in arguments.uniqueBy) { + if (Len(local.keyList)) local.keyList &= ", "; + local.keyList &= $quoteIdentifier(local.u); + } + + ArrayAppend(local.sql, "MERGE INTO #arguments.tableName# (#local.colList#) KEY (#local.keyList#) VALUES "); + + // Build value rows. + for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, ", "); + } + ArrayAppend(local.sql, "("); + for (local.p = 1; local.p <= ArrayLen(arguments.validProperties); local.p++) { + if (local.p > 1) ArrayAppend(local.sql, ", "); + local.propName = arguments.validProperties[local.p]; + local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : ""; + ArrayAppend(local.sql, $buildBulkParam(value=local.val, propName=local.propName, propertyInfo=arguments.propertyInfo)); + } + ArrayAppend(local.sql, ")"); + } + + return local.sql; + } } diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 6e654e8aab..750c15bc6f 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -272,4 +272,76 @@ component extends="wheels.databaseAdapters.Base" output=false { return "[#arguments.name#]"; } + /** + * SQL Server upsert using MERGE statement syntax. + */ + public array function $upsertSQL( + required string tableName, + required array columns, + required array uniqueBy, + required array updateColumns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd, + required struct propertyInfo + ) { + local.sql = []; + + // Build column list for SELECT. + local.colList = ""; + local.selectCols = ""; + for (local.c = 1; local.c <= ArrayLen(arguments.columns); local.c++) { + if (Len(local.colList)) { + local.colList &= ", "; + local.selectCols &= ", "; + } + local.colList &= $quoteIdentifier(arguments.columns[local.c]); + local.selectCols &= "source." & $quoteIdentifier(arguments.columns[local.c]); + } + + // MERGE INTO target USING (VALUES rows) AS source(cols) ON match + ArrayAppend(local.sql, "MERGE INTO #arguments.tableName# WITH (HOLDLOCK) AS target USING (VALUES "); + + // Build value rows. + for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, ", "); + } + ArrayAppend(local.sql, "("); + for (local.p = 1; local.p <= ArrayLen(arguments.validProperties); local.p++) { + if (local.p > 1) ArrayAppend(local.sql, ", "); + local.propName = arguments.validProperties[local.p]; + local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : ""; + ArrayAppend(local.sql, $buildBulkParam(value=local.val, propName=local.propName, propertyInfo=arguments.propertyInfo)); + } + ArrayAppend(local.sql, ")"); + } + + ArrayAppend(local.sql, ") AS source (#local.colList#) ON "); + + // ON clause. + local.onClause = ""; + for (local.u in arguments.uniqueBy) { + if (Len(local.onClause)) local.onClause &= " AND "; + local.onClause &= "target." & $quoteIdentifier(local.u) & " = source." & $quoteIdentifier(local.u); + } + ArrayAppend(local.sql, local.onClause); + + // WHEN MATCHED THEN UPDATE. + if (ArrayLen(arguments.updateColumns)) { + local.setClause = ""; + for (local.uc in arguments.updateColumns) { + if (Len(local.setClause)) local.setClause &= ", "; + local.setClause &= "target." & $quoteIdentifier(local.uc) & " = source." & $quoteIdentifier(local.uc); + } + ArrayAppend(local.sql, " WHEN MATCHED THEN UPDATE SET #local.setClause#"); + } + + // WHEN NOT MATCHED THEN INSERT. + ArrayAppend(local.sql, " WHEN NOT MATCHED THEN INSERT (#local.colList#) VALUES (#local.selectCols#);"); + + return local.sql; + } + } diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc index ff7d6df0ed..3d6fdfbc8f 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc @@ -123,4 +123,57 @@ component extends="wheels.databaseAdapters.Base" output=false { return "`#arguments.name#`"; } + /** + * MySQL upsert using ON DUPLICATE KEY UPDATE col = VALUES(col) syntax. + */ + public array function $upsertSQL( + required string tableName, + required array columns, + required array uniqueBy, + required array updateColumns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd, + required struct propertyInfo + ) { + local.sql = []; + + // Build column list. + local.colList = ""; + for (local.col in arguments.columns) { + if (Len(local.colList)) local.colList &= ", "; + local.colList &= $quoteIdentifier(local.col); + } + + ArrayAppend(local.sql, "INSERT INTO #arguments.tableName# (#local.colList#) VALUES "); + + // Build value rows. + for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, ", "); + } + ArrayAppend(local.sql, "("); + for (local.p = 1; local.p <= ArrayLen(arguments.validProperties); local.p++) { + if (local.p > 1) ArrayAppend(local.sql, ", "); + local.propName = arguments.validProperties[local.p]; + local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : ""; + ArrayAppend(local.sql, $buildBulkParam(value=local.val, propName=local.propName, propertyInfo=arguments.propertyInfo)); + } + ArrayAppend(local.sql, ")"); + } + + // ON DUPLICATE KEY UPDATE clause. + if (ArrayLen(arguments.updateColumns)) { + local.setClause = ""; + for (local.uc in arguments.updateColumns) { + if (Len(local.setClause)) local.setClause &= ", "; + local.setClause &= $quoteIdentifier(local.uc) & " = VALUES(" & $quoteIdentifier(local.uc) & ")"; + } + ArrayAppend(local.sql, " ON DUPLICATE KEY UPDATE #local.setClause#"); + } + + return local.sql; + } + } diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc index 53c84d4a67..0ad78c10ca 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc @@ -154,4 +154,78 @@ component extends="wheels.databaseAdapters.Base" output=false { return """#UCase(arguments.name)#"""; } + /** + * Oracle upsert using MERGE with USING (SELECT ... FROM dual UNION ALL ...) source. + * Uses parameterized values via $buildBulkParam — never interpolates user data into SQL. + */ + public array function $upsertSQL( + required string tableName, + required array columns, + required array uniqueBy, + required array updateColumns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd, + required struct propertyInfo + ) { + local.sql = []; + + ArrayAppend(local.sql, "MERGE INTO #arguments.tableName# target USING ("); + + // Build USING subquery: SELECT ? AS col1, ? AS col2 FROM dual UNION ALL SELECT ?, ? FROM dual ... + for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, " UNION ALL "); + } + ArrayAppend(local.sql, "SELECT "); + for (local.p = 1; local.p <= ArrayLen(arguments.validProperties); local.p++) { + if (local.p > 1) ArrayAppend(local.sql, ", "); + local.propName = arguments.validProperties[local.p]; + local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : ""; + ArrayAppend(local.sql, $buildBulkParam(value=local.val, propName=local.propName, propertyInfo=arguments.propertyInfo)); + // Only the first row needs column aliases; subsequent rows in UNION ALL inherit them. + if (local.r == arguments.batchStart) { + ArrayAppend(local.sql, " AS " & $quoteIdentifier(arguments.columns[local.p])); + } + } + ArrayAppend(local.sql, " FROM dual"); + } + + ArrayAppend(local.sql, ") source ON ("); + + // ON clause. + local.onClause = ""; + for (local.u in arguments.uniqueBy) { + if (Len(local.onClause)) local.onClause &= " AND "; + local.onClause &= "target." & $quoteIdentifier(local.u) & " = source." & $quoteIdentifier(local.u); + } + ArrayAppend(local.sql, local.onClause & ")"); + + // WHEN MATCHED THEN UPDATE. + if (ArrayLen(arguments.updateColumns)) { + local.setClause = ""; + for (local.uc in arguments.updateColumns) { + if (Len(local.setClause)) local.setClause &= ", "; + local.setClause &= "target." & $quoteIdentifier(local.uc) & " = source." & $quoteIdentifier(local.uc); + } + ArrayAppend(local.sql, " WHEN MATCHED THEN UPDATE SET #local.setClause#"); + } + + // WHEN NOT MATCHED THEN INSERT. + local.colList = ""; + local.valList = ""; + for (local.c = 1; local.c <= ArrayLen(arguments.columns); local.c++) { + if (Len(local.colList)) { + local.colList &= ", "; + local.valList &= ", "; + } + local.colList &= $quoteIdentifier(arguments.columns[local.c]); + local.valList &= "source." & $quoteIdentifier(arguments.columns[local.c]); + } + ArrayAppend(local.sql, " WHEN NOT MATCHED THEN INSERT (#local.colList#) VALUES (#local.valList#)"); + + return local.sql; + } + } diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc index 9f6f44ffc2..6c11b18534 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc @@ -188,4 +188,65 @@ component extends="wheels.databaseAdapters.Base" output=false { return """#LCase(arguments.name)#"""; } + /** + * PostgreSQL upsert using ON CONFLICT ... DO UPDATE SET col = EXCLUDED.col syntax. + */ + public array function $upsertSQL( + required string tableName, + required array columns, + required array uniqueBy, + required array updateColumns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd, + required struct propertyInfo + ) { + local.sql = []; + + // Build column list. + local.colList = ""; + for (local.col in arguments.columns) { + if (Len(local.colList)) local.colList &= ", "; + local.colList &= $quoteIdentifier(local.col); + } + + ArrayAppend(local.sql, "INSERT INTO #arguments.tableName# (#local.colList#) VALUES "); + + // Build value rows. + for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, ", "); + } + ArrayAppend(local.sql, "("); + for (local.p = 1; local.p <= ArrayLen(arguments.validProperties); local.p++) { + if (local.p > 1) ArrayAppend(local.sql, ", "); + local.propName = arguments.validProperties[local.p]; + local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : ""; + ArrayAppend(local.sql, $buildBulkParam(value=local.val, propName=local.propName, propertyInfo=arguments.propertyInfo)); + } + ArrayAppend(local.sql, ")"); + } + + // ON CONFLICT clause. + local.uniqueList = ""; + for (local.u in arguments.uniqueBy) { + if (Len(local.uniqueList)) local.uniqueList &= ", "; + local.uniqueList &= $quoteIdentifier(local.u); + } + + if (ArrayLen(arguments.updateColumns)) { + local.setClause = ""; + for (local.uc in arguments.updateColumns) { + if (Len(local.setClause)) local.setClause &= ", "; + local.setClause &= $quoteIdentifier(local.uc) & " = EXCLUDED." & $quoteIdentifier(local.uc); + } + ArrayAppend(local.sql, " ON CONFLICT (#local.uniqueList#) DO UPDATE SET #local.setClause#"); + } else { + ArrayAppend(local.sql, " ON CONFLICT (#local.uniqueList#) DO NOTHING"); + } + + return local.sql; + } + } diff --git a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc index a9e3ec2300..ee04ddc092 100755 --- a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc +++ b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc @@ -137,4 +137,65 @@ component extends="wheels.databaseAdapters.Base" output=false { return """#arguments.name#"""; } + /** + * SQLite upsert using ON CONFLICT ... DO UPDATE SET syntax. + */ + public array function $upsertSQL( + required string tableName, + required array columns, + required array uniqueBy, + required array updateColumns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd, + required struct propertyInfo + ) { + local.sql = []; + + // Build column list. + local.colList = ""; + for (local.col in arguments.columns) { + if (Len(local.colList)) local.colList &= ", "; + local.colList &= $quoteIdentifier(local.col); + } + + ArrayAppend(local.sql, "INSERT INTO #arguments.tableName# (#local.colList#) VALUES "); + + // Build value rows. + for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, ", "); + } + ArrayAppend(local.sql, "("); + for (local.p = 1; local.p <= ArrayLen(arguments.validProperties); local.p++) { + if (local.p > 1) ArrayAppend(local.sql, ", "); + local.propName = arguments.validProperties[local.p]; + local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : ""; + ArrayAppend(local.sql, $buildBulkParam(value=local.val, propName=local.propName, propertyInfo=arguments.propertyInfo)); + } + ArrayAppend(local.sql, ")"); + } + + // ON CONFLICT clause. + local.uniqueList = ""; + for (local.u in arguments.uniqueBy) { + if (Len(local.uniqueList)) local.uniqueList &= ", "; + local.uniqueList &= $quoteIdentifier(local.u); + } + + if (ArrayLen(arguments.updateColumns)) { + local.setClause = ""; + for (local.uc in arguments.updateColumns) { + if (Len(local.setClause)) local.setClause &= ", "; + local.setClause &= $quoteIdentifier(local.uc) & " = excluded." & $quoteIdentifier(local.uc); + } + ArrayAppend(local.sql, " ON CONFLICT (#local.uniqueList#) DO UPDATE SET #local.setClause#"); + } else { + ArrayAppend(local.sql, " ON CONFLICT (#local.uniqueList#) DO NOTHING"); + } + + return local.sql; + } + } diff --git a/vendor/wheels/events/init/functions.cfm b/vendor/wheels/events/init/functions.cfm index 996708d1ce..13d4bcaed8 100644 --- a/vendor/wheels/events/init/functions.cfm +++ b/vendor/wheels/events/init/functions.cfm @@ -49,6 +49,8 @@ application.$wheels.functions.count = {parameterize = true}; application.$wheels.functions.csrfMetaTags = {encode = true}; application.$wheels.functions.create = {parameterize = true, reload = false}; + application.$wheels.functions.insertAll = {parameterize = true}; + application.$wheels.functions.upsertAll = {parameterize = true}; application.$wheels.functions.dateSelect = { label = false, labelPlacement = "around", diff --git a/vendor/wheels/model/bulk.cfc b/vendor/wheels/model/bulk.cfc new file mode 100644 index 0000000000..5896af557f --- /dev/null +++ b/vendor/wheels/model/bulk.cfc @@ -0,0 +1,274 @@ +component { + + /** + * Inserts multiple records into the database in a single batch operation. + * Accepts an array of structs where each struct represents a record to insert. + * All structs must have the same set of keys (property names). + * Batches in groups of 1000 to avoid database parameter limits. + * + * [section: Model Class] + * [category: Create Functions] + * + * @records Array of structs, each containing property name/value pairs to insert. + * @timestamps Set to `false` to skip automatic `createdAt`/`updatedAt` timestamping. + * @transaction [see:save]. + * @parameterize [see:findAll]. + */ + public struct function insertAll( + required array records, + boolean timestamps = true, + string transaction = $get("transactionMode"), + any parameterize + ) { + $args(name = "insertAll", args = arguments); + + if (!ArrayLen(arguments.records)) { + return {insertedCount: 0}; + } + + $validateBulkRecordKeys(arguments.records); + + if (arguments.timestamps) { + arguments.records = $addBulkTimestamps(records = arguments.records, isInsert = true); + } + + local.mapped = $mapBulkProperties(arguments.records); + + // Batch in groups of 1000 rows. + local.batchSize = 1000; + local.totalInserted = 0; + local.totalRecords = ArrayLen(arguments.records); + + for (local.batchStart = 1; local.batchStart <= local.totalRecords; local.batchStart += local.batchSize) { + local.batchEnd = Min(local.batchStart + local.batchSize - 1, local.totalRecords); + + local.sql = $buildBulkInsertSQL( + columns = local.mapped.columns, + validProperties = local.mapped.validProperties, + records = arguments.records, + batchStart = local.batchStart, + batchEnd = local.batchEnd + ); + + variables.wheels.class.adapter.$querySetup( + parameterize = arguments.parameterize, + sql = local.sql + ); + + local.totalInserted += (local.batchEnd - local.batchStart + 1); + } + + $clearRequestCache(); + return {insertedCount: local.totalInserted}; + } + + /** + * Inserts or updates multiple records in a single batch operation (upsert). + * Uses database-specific conflict resolution syntax (e.g., `ON CONFLICT ... DO UPDATE` for PostgreSQL/SQLite). + * The `uniqueBy` argument specifies which properties form the unique constraint for conflict detection. + * + * [section: Model Class] + * [category: Create Functions] + * + * @records Array of structs, each containing property name/value pairs. + * @uniqueBy Comma-delimited list of property names that form the unique constraint for conflict detection. + * @timestamps Set to `false` to skip automatic `createdAt`/`updatedAt` timestamping. + * @transaction [see:save]. + * @parameterize [see:findAll]. + */ + public struct function upsertAll( + required array records, + required string uniqueBy, + boolean timestamps = true, + string transaction = $get("transactionMode"), + any parameterize + ) { + $args(name = "upsertAll", args = arguments); + + if (!ArrayLen(arguments.records)) { + return {upsertedCount: 0}; + } + + $validateBulkRecordKeys(arguments.records); + + if (arguments.timestamps) { + arguments.records = $addBulkTimestamps(records = arguments.records, isInsert = true); + } + + local.mapped = $mapBulkProperties(arguments.records); + + // Map uniqueBy property names to column names. + local.uniqueByList = ListToArray(arguments.uniqueBy); + local.uniqueByColumns = []; + for (local.uProp in local.uniqueByList) { + local.uProp = Trim(local.uProp); + if (!StructKeyExists(variables.wheels.class.properties, local.uProp)) { + Throw( + type = "Wheels.InvalidUniqueByProperty", + message = "The uniqueBy property `#local.uProp#` is not a valid property of this model.", + extendedInfo = "Valid properties are: #StructKeyList(variables.wheels.class.properties)#" + ); + } + ArrayAppend(local.uniqueByColumns, variables.wheels.class.properties[local.uProp].column); + } + + // Update columns = all columns except the unique constraint columns. + local.updateColumns = []; + for (local.c = 1; local.c <= ArrayLen(local.mapped.columns); local.c++) { + if (!ArrayFindNoCase(local.uniqueByColumns, local.mapped.columns[local.c])) { + ArrayAppend(local.updateColumns, local.mapped.columns[local.c]); + } + } + + // Batch in groups of 1000 rows. + local.batchSize = 1000; + local.totalUpserted = 0; + local.totalRecords = ArrayLen(arguments.records); + + for (local.batchStart = 1; local.batchStart <= local.totalRecords; local.batchStart += local.batchSize) { + local.batchEnd = Min(local.batchStart + local.batchSize - 1, local.totalRecords); + + local.sql = variables.wheels.class.adapter.$upsertSQL( + tableName = $quotedTableName(), + columns = local.mapped.columns, + uniqueBy = local.uniqueByColumns, + updateColumns = local.updateColumns, + validProperties = local.mapped.validProperties, + records = arguments.records, + batchStart = local.batchStart, + batchEnd = local.batchEnd, + propertyInfo = variables.wheels.class.properties + ); + + variables.wheels.class.adapter.$querySetup( + parameterize = arguments.parameterize, + sql = local.sql + ); + + local.totalUpserted += (local.batchEnd - local.batchStart + 1); + } + + $clearRequestCache(); + return {upsertedCount: local.totalUpserted}; + } + + /** + * Validates that all records in a bulk array have the same set of keys. + */ + public void function $validateBulkRecordKeys(required array records) { + local.referenceKeys = ListSort(StructKeyList(arguments.records[1]), "textnocase"); + local.iEnd = ArrayLen(arguments.records); + for (local.i = 2; local.i <= local.iEnd; local.i++) { + local.currentKeys = ListSort(StructKeyList(arguments.records[local.i]), "textnocase"); + if (local.currentKeys != local.referenceKeys) { + Throw( + type = "Wheels.InvalidRecordKeys", + message = "All records must have the same set of keys.", + extendedInfo = "Record 1 has keys [#local.referenceKeys#] but record #local.i# has keys [#local.currentKeys#]." + ); + } + } + } + + /** + * Maps record property names to database column names, filtering out non-model properties. + * Returns a struct with `columns` and `validProperties` arrays. + */ + public struct function $mapBulkProperties(required array records) { + local.propertyNames = ListToArray(ListSort(StructKeyList(arguments.records[1]), "textnocase")); + local.columns = []; + local.validProperties = []; + for (local.prop in local.propertyNames) { + if (StructKeyExists(variables.wheels.class.properties, local.prop)) { + ArrayAppend(local.columns, variables.wheels.class.properties[local.prop].column); + ArrayAppend(local.validProperties, local.prop); + } + } + + if (!ArrayLen(local.columns)) { + Throw( + type = "Wheels.InvalidProperties", + message = "No valid properties found in the records.", + extendedInfo = "The keys in the record structs must match model property names." + ); + } + + return {columns: local.columns, validProperties: local.validProperties}; + } + + /** + * Builds the SQL array for a multi-row INSERT statement. + * Returns an array compatible with the adapter's `$querySetup()`. + */ + public array function $buildBulkInsertSQL( + required array columns, + required array validProperties, + required array records, + required numeric batchStart, + required numeric batchEnd + ) { + local.sql = []; + + local.colList = ""; + for (local.col in arguments.columns) { + if (Len(local.colList)) { + local.colList &= ", "; + } + local.colList &= $quoteColumn(local.col); + } + + ArrayAppend(local.sql, "INSERT INTO #$quotedTableName()# (#local.colList#) VALUES "); + + local.propCount = ArrayLen(arguments.validProperties); + for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, ", "); + } + ArrayAppend(local.sql, "("); + for (local.p = 1; local.p <= local.propCount; local.p++) { + if (local.p > 1) { + ArrayAppend(local.sql, ", "); + } + local.propName = arguments.validProperties[local.p]; + local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : ""; + ArrayAppend(local.sql, variables.wheels.class.adapter.$buildBulkParam( + value = local.val, + propName = local.propName, + propertyInfo = variables.wheels.class.properties + )); + } + ArrayAppend(local.sql, ")"); + } + + return local.sql; + } + + /** + * Adds `createdAt` and `updatedAt` timestamps to bulk record arrays when the model + * is configured for automatic timestamping. + */ + public array function $addBulkTimestamps(required array records, boolean isInsert = true) { + local.now = $timestamp(variables.wheels.class.timeStampMode); + + if (arguments.isInsert && variables.wheels.class.timeStampingOnCreate) { + local.createProp = variables.wheels.class.timeStampOnCreateProperty; + for (local.i = 1; local.i <= ArrayLen(arguments.records); local.i++) { + if (!StructKeyExists(arguments.records[local.i], local.createProp) || !Len(arguments.records[local.i][local.createProp])) { + arguments.records[local.i][local.createProp] = local.now; + } + } + } + + if (variables.wheels.class.timeStampingOnUpdate) { + local.updateProp = variables.wheels.class.timeStampOnUpdateProperty; + for (local.i = 1; local.i <= ArrayLen(arguments.records); local.i++) { + if (!StructKeyExists(arguments.records[local.i], local.updateProp) || !Len(arguments.records[local.i][local.updateProp])) { + arguments.records[local.i][local.updateProp] = local.now; + } + } + } + + return arguments.records; + } + +} diff --git a/vendor/wheels/tests/_assets/models/BulkItem.cfc b/vendor/wheels/tests/_assets/models/BulkItem.cfc new file mode 100644 index 0000000000..ba9e7896e9 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/BulkItem.cfc @@ -0,0 +1,7 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_bulkitems"); + } + +} diff --git a/vendor/wheels/tests/populate.cfm b/vendor/wheels/tests/populate.cfm index 96452a38dd..3779d7e0bd 100644 --- a/vendor/wheels/tests/populate.cfm +++ b/vendor/wheels/tests/populate.cfm @@ -96,7 +96,7 @@ - + @@ -335,6 +335,21 @@ CREATE TABLE c_o_r_e_CATEGORIES ) #local.storageEngine# + + +CREATE TABLE c_o_r_e_bulkitems +( + id #local.identityColumnType# + ,code varchar(50) NOT NULL + ,name varchar(100) NOT NULL + ,quantity #local.intColumnType# DEFAULT 0 NOT NULL + ,createdat #local.datetimeColumnType# NULL + ,updatedat #local.datetimeColumnType# NULL + ,PRIMARY KEY(id) + ,UNIQUE(code) +) #local.storageEngine# + + CREATE VIEW c_o_r_e_userphotos AS diff --git a/vendor/wheels/tests/specs/model/bulkOperationsSpec.cfc b/vendor/wheels/tests/specs/model/bulkOperationsSpec.cfc new file mode 100644 index 0000000000..58c649809f --- /dev/null +++ b/vendor/wheels/tests/specs/model/bulkOperationsSpec.cfc @@ -0,0 +1,184 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("insertAll", () => { + + it("inserts multiple records in a single call", () => { + transaction action="begin" { + var records = [ + {firstName: "BulkAlice", lastName: "Anderson"}, + {firstName: "BulkBob", lastName: "Brown"}, + {firstName: "BulkCharlie", lastName: "Clark"} + ]; + var result = g.model("author").insertAll(records=records); + + expect(result.insertedCount).toBe(3); + + // Verify they exist. + var found = g.model("author").findAll(where="firstname LIKE 'Bulk%'", order="firstname"); + expect(found.recordCount).toBe(3); + expect(found.firstname[1]).toBe("BulkAlice"); + expect(found.firstname[2]).toBe("BulkBob"); + expect(found.firstname[3]).toBe("BulkCharlie"); + + transaction action="rollback"; + } + }); + + it("returns zero count for empty records array", () => { + var result = g.model("author").insertAll(records=[]); + expect(result.insertedCount).toBe(0); + }); + + it("throws error when records have inconsistent keys", () => { + var ctx = { + records: [ + {firstName: "Alice", lastName: "Anderson"}, + {firstName: "Bob"} + ] + }; + expect(() => { + g.model("author").insertAll(records=ctx.records); + }).toThrow("Wheels.InvalidRecordKeys"); + }); + + it("inserts records with auto timestamps", () => { + transaction action="begin" { + var records = [ + {code: "BULK-TS-1", name: "TimestampItem1", quantity: 10}, + {code: "BULK-TS-2", name: "TimestampItem2", quantity: 20} + ]; + var result = g.model("bulkItem").insertAll(records=records, timestamps=true); + + expect(result.insertedCount).toBe(2); + + var found = g.model("bulkItem").findAll(where="code LIKE 'BULK-TS-%'", order="code"); + expect(found.recordCount).toBe(2); + // createdAt should have been auto-populated. + expect(Len(found.createdAt[1])).toBeGT(0); + expect(Len(found.updatedAt[1])).toBeGT(0); + + transaction action="rollback"; + } + }); + + it("skips timestamps when timestamps argument is false", () => { + transaction action="begin" { + var records = [ + {code: "BULK-NTS-1", name: "NoTimestamp1", quantity: 5} + ]; + var result = g.model("bulkItem").insertAll(records=records, timestamps=false); + + expect(result.insertedCount).toBe(1); + + var found = g.model("bulkItem").findOne(where="code = 'BULK-NTS-1'"); + expect(found).toBeInstanceOf("component"); + expect(found.name).toBe("NoTimestamp1"); + // Timestamps should be empty since the column is nullable and timestamps=false. + expect(Len(Trim(found.createdAt))).toBe(0, "createdAt should be empty when timestamps=false"); + expect(Len(Trim(found.updatedAt))).toBe(0, "updatedAt should be empty when timestamps=false"); + + transaction action="rollback"; + } + }); + + it("handles single record insertion", () => { + transaction action="begin" { + var records = [ + {firstName: "SingleBulk", lastName: "Test"} + ]; + var result = g.model("author").insertAll(records=records); + + expect(result.insertedCount).toBe(1); + + var found = g.model("author").findOne(where="firstname = 'SingleBulk'"); + expect(found).toBeInstanceOf("component"); + expect(found.lastName).toBe("Test"); + + transaction action="rollback"; + } + }); + + }); + + describe("upsertAll", () => { + + it("inserts new records when no conflict exists", () => { + transaction action="begin" { + var records = [ + {code: "UPSERT-NEW-1", name: "Item1", quantity: 10}, + {code: "UPSERT-NEW-2", name: "Item2", quantity: 20} + ]; + var result = g.model("bulkItem").upsertAll(records=records, uniqueBy="code"); + + expect(result.upsertedCount).toBe(2); + + var found = g.model("bulkItem").findAll(where="code LIKE 'UPSERT-NEW-%'", order="code"); + expect(found.recordCount).toBe(2); + expect(found.name[1]).toBe("Item1"); + expect(found.name[2]).toBe("Item2"); + + transaction action="rollback"; + } + }); + + it("updates existing records on conflict", () => { + transaction action="begin" { + // First insert. + var records = [ + {code: "UPSERT-UPD-1", name: "Original", quantity: 5} + ]; + g.model("bulkItem").upsertAll(records=records, uniqueBy="code"); + + // Upsert with updated values. + var records2 = [ + {code: "UPSERT-UPD-1", name: "Updated", quantity: 99} + ]; + var result = g.model("bulkItem").upsertAll(records=records2, uniqueBy="code"); + + expect(result.upsertedCount).toBe(1); + + var found = g.model("bulkItem").findOne(where="code = 'UPSERT-UPD-1'"); + expect(found.name).toBe("Updated"); + expect(found.quantity).toBe(99); + + transaction action="rollback"; + } + }); + + it("returns zero count for empty records array", () => { + var result = g.model("bulkItem").upsertAll(records=[], uniqueBy="code"); + expect(result.upsertedCount).toBe(0); + }); + + it("throws error for invalid uniqueBy property", () => { + var ctx = { + records: [ + {code: "Test", name: "Desc", quantity: 1} + ] + }; + expect(() => { + g.model("bulkItem").upsertAll(records=ctx.records, uniqueBy="nonExistentProp"); + }).toThrow("Wheels.InvalidUniqueByProperty"); + }); + + it("throws error when records have inconsistent keys", () => { + var ctx = { + records: [ + {code: "A", name: "Name1", quantity: 1}, + {code: "B", name: "Name2"} + ] + }; + expect(() => { + g.model("bulkItem").upsertAll(records=ctx.records, uniqueBy="code"); + }).toThrow("Wheels.InvalidRecordKeys"); + }); + + }); + + } + +}