diff --git a/CHANGELOG.md b/CHANGELOG.md index 7199247..e2fdb4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres timestamptz / bytea / jsonb / arrays (#789)** — Table Browser and SQL results encode cells as PG literals (ISO timestamps, `\x` hex for bytea, JSON text for jsonb, `{1,2,3}` for arrays) instead of `Object.toString`. Schema uses `udt_name` so `ARRAY` / `USER-DEFINED` round-trip through `formatLiteral`. - **Postgres host/port SSL (#790)** — Host/port Use SSL/TLS stays `sslmode=require` (encrypt, no CA check) unless a Root CA is set, then `verify-full`. A URI `sslmode=` value still wins. The form documents the MITM tradeoff and writes `sslmode=verify-full` when saving a Root CA. - **Postgres custom-SQL allowlist (#791)** — Table Browser SQL dialog classifies the first statement after comments instead of substring `contains('insert ')`. `SELECT inserted_at` is allowed; `SELECT 1; DELETE FROM t` is rejected. `WITH … INSERT` is a write; `TABLE` / `VALUES` / `(SELECT …)` stay allowed. - **SQLite getObjectDdl bind (#796)** — DDL lookup uses `WHERE name = ?` with a positional list. `:name` plus `[objectName]` did not bind, so the dialog showed **No definition found** for tables that exist. diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index dffbfb7..e6190e3 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:postgres/src/connection_string.dart' show parseConnectionString; import 'postgres_metadata.dart'; +import 'postgres_result_cells.dart'; import 'table_schema_meta.dart'; /// Replaces the database in a `postgresql://` / `postgres://` URI (path or @@ -421,7 +422,7 @@ class PostgresConnection { } final colsRs = await _conn!.execute( Sql.named( - 'SELECT column_name, data_type, is_nullable, column_default, ' + 'SELECT column_name, data_type, udt_name, is_nullable, column_default, ' 'is_generated, is_identity, identity_generation ' 'FROM information_schema.columns ' 'WHERE table_schema = @schema AND table_name = @table ' @@ -450,15 +451,18 @@ class PostgresConnection { for (final r in colsRs) { final name = r[0] as String? ?? ''; - final dataType = r[1] as String? ?? ''; - final isNullable = (r[2] as String? ?? 'YES').toUpperCase() == 'YES'; + final dataType = postgresColumnSchemaType( + dataType: r[1] as String? ?? '', + udtName: r[2] as String? ?? '', + ); + final isNullable = (r[3] as String? ?? 'YES').toUpperCase() == 'YES'; final isPk = primaryKeys.contains(name); final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; - final dflt = r[3]?.toString(); + final dflt = r[4]?.toString(); final isGenerated = - (r[4] as String? ?? 'NEVER').toUpperCase() == 'ALWAYS'; - final isIdentity = (r[5] as String? ?? 'NO').toUpperCase() == 'YES'; - final identityGeneration = (r[6] as String? ?? '').toUpperCase(); + (r[5] as String? ?? 'NEVER').toUpperCase() == 'ALWAYS'; + final isIdentity = (r[6] as String? ?? 'NO').toUpperCase() == 'YES'; + final identityGeneration = (r[7] as String? ?? '').toUpperCase(); final omitOnInsert = isGenerated || (isIdentity && identityGeneration == 'ALWAYS'); final hasServerDefault = (dflt != null && dflt.isNotEmpty) || diff --git a/lib/core/database/postgres_result_cells.dart b/lib/core/database/postgres_result_cells.dart new file mode 100644 index 0000000..307daa4 --- /dev/null +++ b/lib/core/database/postgres_result_cells.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:postgres/postgres.dart'; + +/// Schema type for the grid codec: prefer `udt_name` over `data_type`. +/// +/// `information_schema.columns.data_type` is `ARRAY` / `USER-DEFINED` for +/// arrays, enums, and domains — too coarse for `formatLiteral`. +String postgresColumnSchemaType({ + required String dataType, + required String udtName, +}) { + final dt = dataType.trim(); + final udt = udtName.trim(); + final dtLower = dt.toLowerCase(); + if (dtLower == 'array') { + if (udt.startsWith('_') && udt.length > 1) { + return '${udt.substring(1)}[]'; + } + return udt.isNotEmpty ? '$udt[]' : dt; + } + if (dtLower == 'user-defined') { + return udt.isNotEmpty ? udt : dt; + } + if (udt.isNotEmpty) return udt; + return dt; +} + +/// PG-literal-friendly cell text: ISO timestamps, `\x` hex for bytea, JSON +/// for jsonb, `{1,2,3}` for arrays. Null → `NULL`. +String postgresResultCellToDisplayString( + Object? value, { + int? typeOid, + String? dataTypeName, +}) { + if (value == null) return 'NULL'; + if (value is UndecodedBytes) return value.asString; + + if (value is Uint8List) { + return postgresByteaHexDisplay(value); + } + if (_isByteaType(typeOid, dataTypeName)) { + if (value is List) { + return postgresByteaHexDisplay(Uint8List.fromList(value)); + } + return postgresByteaHexDisplayFromCell(value); + } + + if (value is DateTime) { + if (_isDateOnly(typeOid, dataTypeName)) { + return value.toIso8601String().split('T').first; + } + return value.toIso8601String(); + } + + if (value is bool) return value ? 'true' : 'false'; + + if (_isJsonType(typeOid, dataTypeName) || value is Map) { + if (value is String) return value; + try { + return json.encode(value); + } catch (_) { + return value.toString(); + } + } + + if (value is List) { + return postgresArrayLiteral(value); + } + + return value.toString(); +} + +/// PostgreSQL bytea text `\xdeadbeef`. +String postgresByteaHexDisplay(Uint8List bytes) { + final out = StringBuffer(r'\x'); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + return out.toString(); +} + +String postgresByteaHexDisplayFromCell(Object value) { + final raw = value.toString().trim(); + if (raw == 'NULL' || raw == 'null') return 'NULL'; + var hex = raw; + 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); + } else { + return postgresByteaHexDisplay( + Uint8List.fromList(raw.codeUnits.map((u) => u & 0xFF).toList()), + ); + } + final clean = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), '').toLowerCase(); + return '\\x$clean'; +} + +/// PostgreSQL array text `{1,2,3}` / `{Alpha,"a b"}` (not Dart `[…]`). +String postgresArrayLiteral(List items) { + final inner = items.map(_postgresArrayElement).join(','); + return '{$inner}'; +} + +String _postgresArrayElement(Object? e) { + if (e == null) return 'NULL'; + if (e is Uint8List) return postgresByteaHexDisplay(e); + if (e is List) return postgresArrayLiteral(e); + if (e is Map) { + return _postgresArrayQuote(json.encode(e)); + } + if (e is DateTime) return e.toIso8601String(); + if (e is bool) return e ? 't' : 'f'; + if (e is String) return _postgresArrayQuote(e); + return e.toString(); +} + +String _postgresArrayQuote(String s) { + return '"${s.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'; +} + +bool _isByteaType(int? typeOid, String? dataTypeName) { + if (typeOid != null && typeOid == Type.byteArray.oid) return true; + final t = dataTypeName?.toLowerCase() ?? ''; + if (_isPgArrayTypeName(t)) return false; + return t.contains('bytea'); +} + +bool _isJsonType(int? typeOid, String? dataTypeName) { + if (typeOid != null && + (typeOid == Type.json.oid || typeOid == Type.jsonb.oid)) { + return true; + } + final t = dataTypeName?.toLowerCase() ?? ''; + if (_isPgArrayTypeName(t)) return false; + return t.contains('json'); +} + +bool _isDateOnly(int? typeOid, String? dataTypeName) { + if (typeOid != null && typeOid == Type.date.oid) return true; + final t = dataTypeName?.toLowerCase().trim() ?? ''; + return t == 'date'; +} + +bool _isPgArrayTypeName(String lower) { + return lower.contains('[]') || + (lower.startsWith('_') && !lower.contains(' ')); +} diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index b414ea6..e8790fc 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -142,6 +142,13 @@ abstract final class TableMutationEngine { lower.startsWith('bit'); } + static bool _isArrayType(String dataTypeName) { + final lower = dataTypeName.toLowerCase().trim(); + if (lower.contains('[]')) return true; + if (lower == 'array') return true; + return lower.startsWith('_') && !lower.contains(' '); + } + /// Public alias of [_isBoolType] for grid display / validators. static bool isBoolType(String dataTypeName) => _isBoolType(dataTypeName); @@ -165,6 +172,13 @@ abstract final class TableMutationEngine { final trimmed = value.trim(); if (dataTypeName != null && dataTypeName.isNotEmpty) { + if (_isArrayType(dataTypeName)) { + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } + return _formatStringLiteral(value, dialect); + } + if (_isBinaryType(dataTypeName)) { if (trimmed == 'NULL' || trimmed == 'null') { return 'NULL'; diff --git a/lib/features/postgresql/postgres_result_utils.dart b/lib/features/postgresql/postgres_result_utils.dart index a6aaf18..da0e494 100644 --- a/lib/features/postgresql/postgres_result_utils.dart +++ b/lib/features/postgresql/postgres_result_utils.dart @@ -1,19 +1,32 @@ -/// Serializable row batch for [convertPostgresResultRowsToStrings] in a worker isolate. +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; + +/// Serializable row batch for [convertPostgresResultRowsToStrings]. class PostgresResultConvertJob { const PostgresResultConvertJob({ required this.rowValues, + this.columnTypeOids, + this.columnDataTypes, }); final List> rowValues; + final List? columnTypeOids; + final List? columnDataTypes; } -/// Converts PostgreSQL result cell values to display strings off the UI thread. -List> convertPostgresResultRowsToStrings(PostgresResultConvertJob job) { - return job.rowValues - .map( - (row) => row - .map((value) => value == null ? 'NULL' : value.toString()) - .toList(), - ) - .toList(); +/// Converts PostgreSQL result cells to PG-literal-friendly display strings. +List> convertPostgresResultRowsToStrings( + PostgresResultConvertJob job) { + final oids = job.columnTypeOids; + final types = job.columnDataTypes; + return [ + for (final row in job.rowValues) + [ + for (var i = 0; i < row.length; i++) + postgresResultCellToDisplayString( + row[i], + typeOid: oids != null && i < oids.length ? oids[i] : null, + dataTypeName: types != null && i < types.length ? types[i] : null, + ), + ], + ]; } diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 46f95e6..8574f96 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -18,6 +18,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; @@ -475,7 +476,15 @@ class _PostgresSqlWorkspaceState extends material.State { n++; } - final outRows = await convertResultRowsToStringsAdaptive(rawRows); + final converted = convertPostgresResultRowsToStrings( + PostgresResultConvertJob( + rowValues: rawRows, + columnTypeOids: [ + for (final c in schema.columns) c.typeOid, + ], + ), + ); + final outRows = await convertResultRowsToStringsAdaptive(converted); final target = SqlTableTargetExtractor.extract(userSql); var pks = const []; diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index e4bfb33..029c635 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -1,6 +1,7 @@ /// Utilities for PostgreSQL table data view (quoting, row conversion). library; +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; /// Default page size for table browse and the SQL template filled from the tree. @@ -24,13 +25,10 @@ String quotePostgresIdentifier(String name) { /// Converts raw result rows (list of dynamic values per row) to list of string rows. List> convertResultRowsToStrings(List> rawRows) { - return rawRows.map((row) { - return row.map((value) { - if (value == null) return 'NULL'; - if (value is DateTime) return value.toIso8601String(); - return value.toString(); - }).toList(); - }).toList(); + return [ + for (final row in rawRows) + [for (final value in row) postgresResultCellToDisplayString(value)], + ]; } /// Whether [sql] is allowed for the Table Browser custom-SQL dialog. diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 48fe547..e06fdbb 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:postgres/postgres.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; @@ -9,6 +10,7 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_editor_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_privileges_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_toolbar.dart'; @@ -251,6 +253,28 @@ class _PostgresTableViewState extends material.State { ); } + Future>> _postgresRowsToDisplayStrings( + Result result, + List colNames, + ) async { + final rawRows = >[ + for (final row in result) + List.generate(row.length, (i) => row[i]), + ]; + final converted = convertPostgresResultRowsToStrings( + PostgresResultConvertJob( + rowValues: rawRows, + columnTypeOids: [ + for (final c in result.schema.columns) c.typeOid, + ], + columnDataTypes: [ + for (final n in colNames) _columnDataTypes[n], + ], + ), + ); + return convertResultRowsToStringsAdaptive(converted); + } + /// [refreshCount] runs `COUNT(*)` (e.g. first load or Refresh). Pagination only runs SELECT. Future _fetch({bool refreshCount = false}) async { final conn = _connection; @@ -296,12 +320,7 @@ class _PostgresTableViewState extends material.State { (i) => result.schema.columns[i].columnName ?? 'col_$i', ); - final rawRows = >[ - for (final row in result) - List.generate(row.length, (i) => row[i]), - ]; - - final stringRows = await convertResultRowsToStringsAdaptive(rawRows); + final stringRows = await _postgresRowsToDisplayStrings(result, colNames); if (!mounted) return; setState(() { @@ -351,12 +370,7 @@ class _PostgresTableViewState extends material.State { (i) => result.schema.columns[i].columnName ?? 'col_$i', ); - final rawRows = >[ - for (final row in result) - List.generate(row.length, (i) => row[i]), - ]; - - final stringRows = await convertResultRowsToStringsAdaptive(rawRows); + final stringRows = await _postgresRowsToDisplayStrings(result, colNames); if (!mounted) return; setState(() { diff --git a/lib/features/workspace/grid_data_type_validator.dart b/lib/features/workspace/grid_data_type_validator.dart index 4eb2f69..0b1c117 100644 --- a/lib/features/workspace/grid_data_type_validator.dart +++ b/lib/features/workspace/grid_data_type_validator.dart @@ -24,6 +24,11 @@ abstract final class GridDataTypeValidator { final type = dataTypeName.toLowerCase().trim(); + // PostgreSQL arrays (`integer[]`, `_int4`) are not scalar ints/json. + if (type.contains('[]') || (type.startsWith('_') && !type.contains(' '))) { + return null; + } + // Boolean types (MySQL BOOLEAN is TINYINT(1) — check before generic int) if (type == 'bool' || type == 'boolean' || type.startsWith('tinyint(1)')) { final lower = value.toLowerCase().trim(); diff --git a/test/core/database/postgres_grid_types_test.dart b/test/core/database/postgres_grid_types_test.dart new file mode 100644 index 0000000..eeca3dd --- /dev/null +++ b/test/core/database/postgres_grid_types_test.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:postgres/postgres.dart'; +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; +import 'package:querya_desktop/features/workspace/grid_data_type_validator.dart'; + +void main() { + group('postgresColumnSchemaType', () { + test('ARRAY uses udt_name element plus []', () { + expect( + postgresColumnSchemaType(dataType: 'ARRAY', udtName: '_int4'), + 'int4[]', + ); + expect( + postgresColumnSchemaType(dataType: 'ARRAY', udtName: '_jsonb'), + 'jsonb[]', + ); + }); + + test('USER-DEFINED uses udt_name (enum / domain)', () { + expect( + postgresColumnSchemaType( + dataType: 'USER-DEFINED', + udtName: 'user_role_enum', + ), + 'user_role_enum', + ); + }); + + test('prefers udt_name over data_type', () { + expect( + postgresColumnSchemaType( + dataType: 'timestamp with time zone', + udtName: 'timestamptz', + ), + 'timestamptz', + ); + expect( + postgresColumnSchemaType(dataType: 'bytea', udtName: 'bytea'), + 'bytea', + ); + expect( + postgresColumnSchemaType(dataType: 'jsonb', udtName: 'jsonb'), + 'jsonb', + ); + }); + }); + + group('all_types display + persist', () { + test('timestamptz DateTime displays ISO and persists quoted', () { + final dt = DateTime.utc(2026, 8, 26, 10, 30); + expect( + postgresResultCellToDisplayString( + dt, + typeOid: Type.timestampTz.oid, + dataTypeName: 'timestamptz', + ), + dt.toIso8601String(), + ); + expect( + TableMutationEngine.formatLiteral( + dt.toIso8601String(), + SqlDialect.postgres, + dataTypeName: 'timestamptz', + ), + "'${dt.toIso8601String()}'", + ); + }); + + test('bytea displays \\x hex and persists as bytea literal', () { + expect( + postgresResultCellToDisplayString( + Uint8List.fromList(const [0xde, 0xad, 0xbe, 0xef]), + dataTypeName: 'bytea', + ), + r'\xdeadbeef', + ); + expect( + TableMutationEngine.formatLiteral( + r'\xdeadbeef', + SqlDialect.postgres, + dataTypeName: 'bytea', + ), + r"'\xdeadbeef'::bytea", + ); + }); + + test('jsonb Map displays JSON text and persists quoted', () { + const decoded = { + 'id': 42, + 'user': {'email': 'alice@querya.dev'}, + }; + final shown = postgresResultCellToDisplayString( + decoded, + typeOid: Type.jsonb.oid, + dataTypeName: 'jsonb', + ); + expect(json.decode(shown), decoded); + expect( + TableMutationEngine.formatLiteral( + shown, + SqlDialect.postgres, + dataTypeName: 'jsonb', + ), + "'$shown'", + ); + }); + + test('enum stays a quoted label', () { + expect( + postgresResultCellToDisplayString( + 'admin', + dataTypeName: 'user_role_enum', + ), + 'admin', + ); + expect( + TableMutationEngine.formatLiteral( + 'admin', + SqlDialect.postgres, + dataTypeName: 'user_role_enum', + ), + "'admin'", + ); + }); + + test('int[] displays PG array text and persists quoted', () { + expect( + postgresResultCellToDisplayString( + [1, 2, 3, 42, 999], + dataTypeName: 'int4[]', + ), + '{1,2,3,42,999}', + ); + expect( + TableMutationEngine.formatLiteral( + '{1,2,3,42,999}', + SqlDialect.postgres, + dataTypeName: 'int4[]', + ), + "'{1,2,3,42,999}'", + ); + expect( + GridDataTypeValidator.validate('{1,2,3}', dataTypeName: 'int4[]'), + isNull, + ); + }); + }); + + group('convertPostgresResultRowsToStrings', () { + test('maps timestamptz, bytea, jsonb, array — not Object.toString', () { + final dt = DateTime.utc(2026, 1, 1, 0, 0, 0); + final out = convertPostgresResultRowsToStrings( + PostgresResultConvertJob( + rowValues: [ + [ + dt, + Uint8List.fromList(const [0xca, 0xfe]), + {'a': true}, + [10, 20], + null, + ], + ], + columnDataTypes: const [ + 'timestamptz', + 'bytea', + 'jsonb', + 'int4[]', + 'text', + ], + ), + ); + expect(out.single, [ + dt.toIso8601String(), + r'\xcafe', + '{"a":true}', + '{10,20}', + 'NULL', + ]); + }); + }); +} diff --git a/test/features/workspace/grid_data_type_validator_test.dart b/test/features/workspace/grid_data_type_validator_test.dart index 303e67b..0300415 100644 --- a/test/features/workspace/grid_data_type_validator_test.dart +++ b/test/features/workspace/grid_data_type_validator_test.dart @@ -93,6 +93,17 @@ void main() { expect(GridDataTypeValidator.validate('123', dataTypeName: 'bytea'), isNotNull); // odd length hex }); + test('skips scalar checks for PostgreSQL array types', () { + expect( + GridDataTypeValidator.validate('{1,2,3}', dataTypeName: 'integer[]'), + isNull, + ); + expect( + GridDataTypeValidator.validate('{1,2,3}', dataTypeName: '_int4'), + isNull, + ); + }); + test('allows empty and NULL values regardless of type', () { expect(GridDataTypeValidator.validate('', dataTypeName: 'int'), isNull); expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'uuid'), isNull);