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
87 changes: 76 additions & 11 deletions lib/core/database/table_mutation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ abstract final class TableMutationEngine {
final stagedVal = mod.value;
if (colIndex < columns.length) {
final colName = columns[colIndex];
final quotedCol = quoteIdentifier(colName, dialect);
final colType = columnDataTypes?[colName];
final quotedCol = quoteIdentifier(unquoteIdentifier(colName), dialect);
final colType = _lookupDataType(columnDataTypes, colName);
final literal = formatLiteral(stagedVal, dialect, dataTypeName: colType);
setClauses.add('$quotedCol = $literal');
}
Expand Down Expand Up @@ -339,7 +339,7 @@ abstract final class TableMutationEngine {

for (var c = 0; c < columns.length; c++) {
final colName = columns[c];
final meta = columnMeta?[colName];
final meta = _lookupColumnMeta(columnMeta, colName);
if (meta?.omitOnInsert == true) continue;

final cellVal = c < row.length ? row[c] : (meta == null ? 'NULL' : '');
Expand All @@ -349,8 +349,8 @@ abstract final class TableMutationEngine {
continue;
}

final quotedCol = quoteIdentifier(colName, dialect);
final colType = columnDataTypes?[colName] ?? meta?.dataType;
final quotedCol = quoteIdentifier(unquoteIdentifier(colName), dialect);
final colType = _lookupDataType(columnDataTypes, colName) ?? meta?.dataType;
colNames.add(quotedCol);
values.add(formatLiteral(cellVal, dialect, dataTypeName: colType));
}
Expand Down Expand Up @@ -401,10 +401,69 @@ abstract final class TableMutationEngine {
tableName: tableName,
schema: schema,
statements: statements,
hasPrimaryKey: primaryKeys.isNotEmpty,
hasPrimaryKey: _hasMatchingPrimaryKey(columns: columns, primaryKeys: primaryKeys),
);
}

/// Strips enclosing quotes (`"`, ``` ` ```, or `[]`) from an identifier if present.
static String unquoteIdentifier(String identifier) {
var s = identifier.trim();
if ((s.startsWith('"') && s.endsWith('"') && s.length >= 2) ||
(s.startsWith('`') && s.endsWith('`') && s.length >= 2) ||
(s.startsWith('[') && s.endsWith(']') && s.length >= 2)) {
s = s.substring(1, s.length - 1);
}
return s;
}

/// Normalizes an identifier by stripping enclosing quotes and lowercasing.
static String normalizeIdentifier(String identifier) {
return unquoteIdentifier(identifier).toLowerCase();
}

static String? _lookupDataType(Map<String, String>? columnDataTypes, String colName) {
if (columnDataTypes == null || columnDataTypes.isEmpty) return null;
if (columnDataTypes.containsKey(colName)) return columnDataTypes[colName];
final unquoted = unquoteIdentifier(colName);
if (columnDataTypes.containsKey(unquoted)) return columnDataTypes[unquoted];
final norm = normalizeIdentifier(colName);
for (final entry in columnDataTypes.entries) {
if (normalizeIdentifier(entry.key) == norm) {
return entry.value;
}
}
return null;
}

static TableColumnMeta? _lookupColumnMeta(
Map<String, TableColumnMeta>? columnMeta,
String colName,
) {
if (columnMeta == null || columnMeta.isEmpty) return null;
if (columnMeta.containsKey(colName)) return columnMeta[colName];
final unquoted = unquoteIdentifier(colName);
if (columnMeta.containsKey(unquoted)) return columnMeta[unquoted];
final norm = normalizeIdentifier(colName);
for (final entry in columnMeta.entries) {
if (normalizeIdentifier(entry.key) == norm) {
return entry.value;
}
}
return null;
}

static bool _hasMatchingPrimaryKey({
required List<String> columns,
required List<String> primaryKeys,
}) {
if (primaryKeys.isEmpty) return false;
return primaryKeys.any((pk) {
if (columns.contains(pk)) return true;
final normPk = normalizeIdentifier(pk);
return columns.any((c) => normalizeIdentifier(c) == normPk);
});
}

static String _buildWhereClause({
required List<String> columns,
required List<String> primaryKeys,
Expand All @@ -416,14 +475,20 @@ abstract final class TableMutationEngine {

if (primaryKeys.isNotEmpty) {
for (final pk in primaryKeys) {
final colIdx = columns.indexOf(pk);
var colIdx = columns.indexOf(pk);
if (colIdx == -1) {
final normPk = normalizeIdentifier(pk);
colIdx = columns.indexWhere((c) => normalizeIdentifier(c) == normPk);
}
if (colIdx != -1 && colIdx < row.length) {
final colName = quoteIdentifier(pk, dialect);
final matchedCol = columns[colIdx];
final colName = quoteIdentifier(unquoteIdentifier(matchedCol), dialect);
final val = row[colIdx];
if (val == 'NULL' || val == 'null') {
clauses.add('$colName IS NULL');
} else {
final colType = columnDataTypes?[pk];
final colType = _lookupDataType(columnDataTypes, matchedCol) ??
_lookupDataType(columnDataTypes, pk);
clauses.add('$colName = ${formatLiteral(val, dialect, dataTypeName: colType)}');
}
}
Expand All @@ -434,12 +499,12 @@ abstract final class TableMutationEngine {
if (clauses.isEmpty) {
for (var c = 0; c < columns.length; c++) {
final colName = columns[c];
final quotedCol = quoteIdentifier(colName, dialect);
final quotedCol = quoteIdentifier(unquoteIdentifier(colName), dialect);
final val = c < row.length ? row[c] : 'NULL';
if (val == 'NULL' || val == 'null') {
clauses.add('$quotedCol IS NULL');
} else {
final colType = columnDataTypes?[colName];
final colType = _lookupDataType(columnDataTypes, colName);
clauses.add('$quotedCol = ${formatLiteral(val, dialect, dataTypeName: colType)}');
}
}
Expand Down
62 changes: 62 additions & 0 deletions test/core/database/table_mutation_engine_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -537,5 +537,67 @@ void main() {
);
expect(mysql.statements.first.sql, 'INSERT INTO `t` () VALUES ()');
});

test('resolves primary key with case-insensitive casing (e.g. ID vs id)', () {
final plan = TableMutationEngine.generatePlan(
dialect: SqlDialect.postgres,
tableName: 'users',
columns: ['id', 'username', 'email'],
primaryKeys: ['ID'],
originalRows: [
['42', 'alice', 'alice@example.com'],
],
modifiedCells: {
0: {1: 'alice_new'},
},
insertedRows: const [],
deletedRowIndices: const {},
);

expect(plan.hasPrimaryKey, isTrue);
expect(plan.statementCount, 1);
final stmt = plan.statements.first;
expect(stmt.type, MutationType.update);
// Must generate WHERE "id" = 42, NOT match all columns
expect(
stmt.sql,
'UPDATE "users" SET "username" = \'alice_new\' WHERE "id" = 42',
);
});

test('resolves primary key and column names when enclosed in quotes or with different casing', () {
final plan = TableMutationEngine.generatePlan(
dialect: SqlDialect.postgres,
tableName: 'accounts',
columns: ['"ACCOUNT_ID"', 'status'],
primaryKeys: ['account_id'],
originalRows: [
['100', 'active'],
],
modifiedCells: const {},
insertedRows: const [],
deletedRowIndices: {0},
);

expect(plan.hasPrimaryKey, isTrue);
expect(plan.statementCount, 1);
final stmt = plan.statements.first;
expect(stmt.type, MutationType.delete);
expect(
stmt.sql,
'DELETE FROM "accounts" WHERE "ACCOUNT_ID" = 100',
);
});

test('unquotes and normalizes identifiers helper methods', () {
expect(TableMutationEngine.unquoteIdentifier('"my_col"'), 'my_col');
expect(TableMutationEngine.unquoteIdentifier('`my_col`'), 'my_col');
expect(TableMutationEngine.unquoteIdentifier('[my_col]'), 'my_col');
expect(TableMutationEngine.unquoteIdentifier('my_col'), 'my_col');

expect(TableMutationEngine.normalizeIdentifier('"USER_ID"'), 'user_id');
expect(TableMutationEngine.normalizeIdentifier('`USER_ID`'), 'user_id');
expect(TableMutationEngine.normalizeIdentifier('user_id'), 'user_id');
});
});
}
Loading