Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions vendor/wheels/databaseAdapters/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
}

}
50 changes: 50 additions & 0 deletions vendor/wheels/databaseAdapters/H2/H2Model.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
53 changes: 53 additions & 0 deletions vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
74 changes: 74 additions & 0 deletions vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
61 changes: 61 additions & 0 deletions vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Loading
Loading