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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 11 additions & 7 deletions lib/core/database/postgres_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '
Expand Down Expand Up @@ -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) ||
Expand Down
154 changes: 154 additions & 0 deletions lib/core/database/postgres_result_cells.dart
Original file line number Diff line number Diff line change
@@ -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<int>) {
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<dynamic> 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(' '));
}
14 changes: 14 additions & 0 deletions lib/core/database/table_mutation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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';
Expand Down
33 changes: 23 additions & 10 deletions lib/features/postgresql/postgres_result_utils.dart
Original file line number Diff line number Diff line change
@@ -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<List<Object?>> rowValues;
final List<int>? columnTypeOids;
final List<String?>? columnDataTypes;
}

/// Converts PostgreSQL result cell values to display strings off the UI thread.
List<List<String>> 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<List<String>> 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,
),
],
];
}
11 changes: 10 additions & 1 deletion lib/features/postgresql/postgres_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -475,7 +476,15 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
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 <String>[];
Expand Down
12 changes: 5 additions & 7 deletions lib/features/postgresql/postgres_table_utils.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -24,13 +25,10 @@ String quotePostgresIdentifier(String name) {

/// Converts raw result rows (list of dynamic values per row) to list of string rows.
List<List<String>> convertResultRowsToStrings(List<List<dynamic>> 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.
Expand Down
38 changes: 26 additions & 12 deletions lib/features/postgresql/postgres_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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';
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';
Expand Down Expand Up @@ -251,6 +253,28 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
);
}

Future<List<List<String>>> _postgresRowsToDisplayStrings(
Result result,
List<String> colNames,
) async {
final rawRows = <List<Object?>>[
for (final row in result)
List<Object?>.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<void> _fetch({bool refreshCount = false}) async {
final conn = _connection;
Expand Down Expand Up @@ -296,12 +320,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
(i) => result.schema.columns[i].columnName ?? 'col_$i',
);

final rawRows = <List<Object?>>[
for (final row in result)
List<Object?>.generate(row.length, (i) => row[i]),
];

final stringRows = await convertResultRowsToStringsAdaptive(rawRows);
final stringRows = await _postgresRowsToDisplayStrings(result, colNames);

if (!mounted) return;
setState(() {
Expand Down Expand Up @@ -351,12 +370,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
(i) => result.schema.columns[i].columnName ?? 'col_$i',
);

final rawRows = <List<Object?>>[
for (final row in result)
List<Object?>.generate(row.length, (i) => row[i]),
];

final stringRows = await convertResultRowsToStringsAdaptive(rawRows);
final stringRows = await _postgresRowsToDisplayStrings(result, colNames);

if (!mounted) return;
setState(() {
Expand Down
Loading
Loading