From 813aab1277c839efb99485d05896b7225a736cb9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:59:33 +0300 Subject: [PATCH] feat(workspace): support binary and BLOB data types in DML mutation engine and grid validator - Add _isBinaryType and dialect-specific hex formatting in TableMutationEngine - Format bytea as E'\\x...' / '\\x...'::bytea for PostgreSQL and X'...' for MySQL / SQLite - Add hex validation for blob, binary, varbinary, bytea, and raw in GridDataTypeValidator - Add unit tests for binary formatting and validation across dialects Closes #658 --- lib/core/database/table_mutation_engine.dart | 35 ++++++++++++++++ .../workspace/grid_data_type_validator.dart | 23 ++++++++++ .../database/table_mutation_engine_test.dart | 42 +++++++++++++++++++ .../grid_data_type_validator_test.dart | 10 +++++ 4 files changed, 110 insertions(+) diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index 7f9f305..11049e3 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -129,6 +129,17 @@ abstract final class TableMutationEngine { lower.contains('number'); } + static bool _isBinaryType(String dataTypeName) { + final lower = dataTypeName.toLowerCase().trim(); + return lower.contains('blob') || + lower.contains('bytea') || + lower.contains('binary') || + lower.contains('varbinary') || + lower == 'raw' || + lower == 'image' || + lower.startsWith('bit'); + } + static const String kNullSentinel = '\u0000__QUERYA_NULL__\u0000'; /// Formats a cell string value safely as an SQL literal or `NULL`. @@ -146,6 +157,30 @@ abstract final class TableMutationEngine { final trimmed = value.trim(); if (dataTypeName != null && dataTypeName.isNotEmpty) { + if (_isBinaryType(dataTypeName)) { + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } + var hex = trimmed; + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } else if ((hex.startsWith("x'") || hex.startsWith("X'")) && + hex.endsWith("'")) { + hex = hex.substring(2, hex.length - 1); + } + final cleanHex = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), ''); + switch (dialect) { + case SqlDialect.postgres: + return "'\\x$cleanHex'::bytea"; + case SqlDialect.mysql: + case SqlDialect.sqlite: + return "X'$cleanHex'"; + } + } + if (_isTextType(dataTypeName)) { // String columns: preserve literal 'NULL' or 'null' as a text string final escaped = value.replaceAll("'", "''"); diff --git a/lib/features/workspace/grid_data_type_validator.dart b/lib/features/workspace/grid_data_type_validator.dart index ae999c4..aa3a0d3 100644 --- a/lib/features/workspace/grid_data_type_validator.dart +++ b/lib/features/workspace/grid_data_type_validator.dart @@ -99,6 +99,29 @@ abstract final class GridDataTypeValidator { return null; } + // Binary / BLOB / Bytea + if (type.contains('blob') || + type.contains('bytea') || + type.contains('binary') || + type.contains('varbinary') || + type == 'raw' || + type == 'image') { + var hex = value.trim(); + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } else if ((hex.startsWith("x'") || hex.startsWith("X'")) && + hex.endsWith("'")) { + hex = hex.substring(2, hex.length - 1); + } + if (!RegExp(r'^[0-9a-fA-F]*$').hasMatch(hex) || hex.length.isOdd) { + return 'Expected valid hex string (e.g. \\xDEADBEEF, 0x12AB, or DEADBEEF)'; + } + return null; + } + return null; } } diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index b96b7b7..af23cd4 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -281,5 +281,47 @@ void main() { 'NULL', ); }); + + test('formats binary and BLOB literals correctly across dialects', () { + // PostgreSQL bytea + expect( + TableMutationEngine.formatLiteral( + r'\xDEADBEEF', + SqlDialect.postgres, + dataTypeName: 'bytea', + ), + r"'\xDEADBEEF'::bytea", + ); + + // MySQL blob / varbinary + expect( + TableMutationEngine.formatLiteral( + '0x12AB', + SqlDialect.mysql, + dataTypeName: 'blob', + ), + "X'12AB'", + ); + + // SQLite blob + expect( + TableMutationEngine.formatLiteral( + "X'CAFE'", + SqlDialect.sqlite, + dataTypeName: 'blob', + ), + "X'CAFE'", + ); + + // NULL for binary + expect( + TableMutationEngine.formatLiteral( + 'NULL', + SqlDialect.postgres, + dataTypeName: 'bytea', + ), + 'NULL', + ); + }); }); } diff --git a/test/features/workspace/grid_data_type_validator_test.dart b/test/features/workspace/grid_data_type_validator_test.dart index 276040e..9e5f051 100644 --- a/test/features/workspace/grid_data_type_validator_test.dart +++ b/test/features/workspace/grid_data_type_validator_test.dart @@ -75,10 +75,20 @@ void main() { ); }); + test('validates binary / blob / bytea types', () { + expect(GridDataTypeValidator.validate(r'\xDEADBEEF', dataTypeName: 'bytea'), isNull); + expect(GridDataTypeValidator.validate('0x12AB', dataTypeName: 'blob'), isNull); + expect(GridDataTypeValidator.validate("X'CAFE'", dataTypeName: 'binary'), isNull); + expect(GridDataTypeValidator.validate('DEADBEEF', dataTypeName: 'varbinary'), isNull); + expect(GridDataTypeValidator.validate('not_hex', dataTypeName: 'blob'), isNotNull); + expect(GridDataTypeValidator.validate('123', dataTypeName: 'bytea'), isNotNull); // odd length hex + }); + test('allows empty and NULL values regardless of type', () { expect(GridDataTypeValidator.validate('', dataTypeName: 'int'), isNull); expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'uuid'), isNull); expect(GridDataTypeValidator.validate('null', dataTypeName: 'json'), isNull); + expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'blob'), isNull); }); }); }