From 7c609d0d11673e09e9ffb99cdab7d87f4fdccdb3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 19:52:01 +0300 Subject: [PATCH 1/2] fix(data-grid): distinguish literal string 'NULL' from SQL NULL in TableMutationEngine WHERE clause Closes #1368 --- lib/core/database/table_mutation_engine.dart | 16 ++-- .../database/table_mutation_engine_test.dart | 85 +++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index edda4dbb..910328d5 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -484,11 +484,13 @@ abstract final class TableMutationEngine { final matchedCol = columns[colIdx]; final colName = quoteIdentifier(unquoteIdentifier(matchedCol), dialect); final val = row[colIdx]; - if (val == 'NULL' || val == 'null') { + final colType = _lookupDataType(columnDataTypes, matchedCol) ?? + _lookupDataType(columnDataTypes, pk); + + final isText = colType != null && _isTextType(colType); + if (val == kNullSentinel || (!isText && (val == 'NULL' || val == 'null'))) { clauses.add('$colName IS NULL'); } else { - final colType = _lookupDataType(columnDataTypes, matchedCol) ?? - _lookupDataType(columnDataTypes, pk); clauses.add('$colName = ${formatLiteral(val, dialect, dataTypeName: colType)}'); } } @@ -500,11 +502,13 @@ abstract final class TableMutationEngine { for (var c = 0; c < columns.length; c++) { final colName = columns[c]; final quotedCol = quoteIdentifier(unquoteIdentifier(colName), dialect); - final val = c < row.length ? row[c] : 'NULL'; - if (val == 'NULL' || val == 'null') { + final val = c < row.length ? row[c] : kNullSentinel; + final colType = _lookupDataType(columnDataTypes, colName); + + final isText = colType != null && _isTextType(colType); + if (val == kNullSentinel || (!isText && (val == 'NULL' || val == 'null'))) { clauses.add('$quotedCol IS NULL'); } else { - final colType = _lookupDataType(columnDataTypes, colName); clauses.add('$quotedCol = ${formatLiteral(val, dialect, dataTypeName: colType)}'); } } diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index 99a6c2ad..c491dc6b 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -625,5 +625,90 @@ void main() { expect(TableMutationEngine.normalizeIdentifier('`USER_ID`'), 'user_id'); expect(TableMutationEngine.normalizeIdentifier('user_id'), 'user_id'); }); + + test('preserves literal string NULL in WHERE clause for text primary key', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'codes', + columns: ['code', 'description'], + primaryKeys: ['code'], + originalRows: [ + ['NULL', 'Special null code'], + ], + modifiedCells: { + 0: {1: 'Updated description'}, + }, + insertedRows: const [], + deletedRowIndices: const {}, + columnMeta: { + 'code': const TableColumnMeta(name: 'code', dataType: 'varchar'), + 'description': const TableColumnMeta(name: 'description', dataType: 'text'), + }, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.update); + expect( + stmt.sql, + "UPDATE \"codes\" SET \"description\" = 'Updated description' WHERE \"code\" = 'NULL'", + ); + }); + + test('uses IS NULL in WHERE clause when value is kNullSentinel in text column', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'codes', + columns: ['code', 'description'], + primaryKeys: ['code'], + originalRows: [ + [TableMutationEngine.kNullSentinel, 'Special null code'], + ], + modifiedCells: { + 0: {1: 'Updated description'}, + }, + insertedRows: const [], + deletedRowIndices: const {}, + columnMeta: { + 'code': const TableColumnMeta(name: 'code', dataType: 'varchar'), + 'description': const TableColumnMeta(name: 'description', dataType: 'text'), + }, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.update); + expect( + stmt.sql, + "UPDATE \"codes\" SET \"description\" = 'Updated description' WHERE \"code\" IS NULL", + ); + }); + + test('preserves literal string NULL in unkeyed row match-all WHERE clause for text columns', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.mysql, + tableName: 'logs', + columns: ['tag', 'status'], + primaryKeys: const [], + originalRows: [ + ['NULL', 'null'], + ], + modifiedCells: const {}, + insertedRows: const [], + deletedRowIndices: {0}, + columnMeta: { + 'tag': const TableColumnMeta(name: 'tag', dataType: 'varchar(50)'), + 'status': const TableColumnMeta(name: 'status', dataType: 'text'), + }, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.delete); + expect( + stmt.sql, + "DELETE FROM `logs` WHERE `tag` = 'NULL' AND `status` = 'null'", + ); + }); }); } From 35f4f9d94e80a84c55e45e7596a8652871245b67 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 19:58:20 +0300 Subject: [PATCH 2/2] fix(data-grid): use columnMeta types when building WHERE clause generatePlan passed only columnDataTypes to _buildWhereClause, so callers supplying types via columnMeta got no text-type detection and the literal string 'NULL' was still rendered as IS NULL. --- lib/core/database/table_mutation_engine.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index 910328d5..b55778f3 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -280,6 +280,14 @@ abstract final class TableMutationEngine { Map? columnDataTypes, Map? columnMeta, }) { + // Types for WHERE matching: explicit columnDataTypes win, columnMeta fills + // the gaps so text columns keep literal 'NULL' strings (#1368). + final whereColumnTypes = { + if (columnMeta != null) + for (final e in columnMeta.entries) + if (e.value.dataType.isNotEmpty) e.key: e.value.dataType, + ...?columnDataTypes, + }; final statements = []; final tableRef = quoteQualifiedTable( tableName, @@ -317,7 +325,7 @@ abstract final class TableMutationEngine { primaryKeys: primaryKeys, row: origRow, dialect: dialect, - columnDataTypes: columnDataTypes, + columnDataTypes: whereColumnTypes, ); final sql = 'UPDATE $tableRef SET ${setClauses.join(', ')} WHERE $whereClause'; @@ -382,7 +390,7 @@ abstract final class TableMutationEngine { primaryKeys: primaryKeys, row: origRow, dialect: dialect, - columnDataTypes: columnDataTypes, + columnDataTypes: whereColumnTypes, ); final sql = 'DELETE FROM $tableRef WHERE $whereClause';