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
35 changes: 35 additions & 0 deletions lib/core/database/table_mutation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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("'", "''");
Expand Down
23 changes: 23 additions & 0 deletions lib/features/workspace/grid_data_type_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
42 changes: 42 additions & 0 deletions test/core/database/table_mutation_engine_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
}
10 changes: 10 additions & 0 deletions test/features/workspace/grid_data_type_validator_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
Loading