From 47c5a8b0cb42f011a22f2d3c2f4a0c53106b4453 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 15:10:26 +0300 Subject: [PATCH] fix(sqlite): columns with the same name stay separate in query results (#1144) rawQuery returns rows as maps keyed by column name, so SELECT o.id, u.id collapsed into one column with the wrong value. Read-only SELECT and WITH statements are now wrapped in SELECT * FROM (...), which makes SQLite name the repeats id and id:1. The table browser's custom SQL and the SQL editor and MCP paths share the wrapper. Unique names and other statements are unchanged. --- lib/core/database/sqlite_sql.dart | 19 +++ lib/features/sqlite/sqlite_sql_workspace.dart | 4 +- lib/features/sqlite/sqlite_table_view.dart | 3 +- .../database/sqlite_unique_columns_test.dart | 128 ++++++++++++++++++ 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 test/core/database/sqlite_unique_columns_test.dart diff --git a/lib/core/database/sqlite_sql.dart b/lib/core/database/sqlite_sql.dart index 9dad60fe..3e8bf065 100644 --- a/lib/core/database/sqlite_sql.dart +++ b/lib/core/database/sqlite_sql.dart @@ -268,6 +268,25 @@ class _SqliteSqlScan { } } +/// [sql] as a `SELECT * FROM (...)` wrapper, so SQLite names every output +/// column uniquely: `SELECT o.id, u.id` gives `id` and `id:1` instead of two +/// `id` columns. `rawQuery` returns rows as maps keyed by name, so repeated +/// names would collapse into one (#1144). +/// +/// Only a single `SELECT` or `WITH` statement is wrapped. Anything else, and +/// several statements at once, come back unchanged. +String sqliteUniqueColumnsSql(String sql) { + final stmt = sqliteStripSqlComments(sql).trim().toLowerCase(); + if (!RegExp(r'^(select|with)\b').hasMatch(stmt)) return sql; + if (sqliteHasMultipleStatements(sql)) return sql; + var body = sql.trim(); + while (body.endsWith(';')) { + body = body.substring(0, body.length - 1).trimRight(); + } + // The newlines keep a trailing `-- comment` from swallowing the `)`. + return 'SELECT * FROM (\n$body\n)'; +} + bool _isIdentStart(int c) => (c >= 0x61 && c <= 0x7a) || c == 0x5f; bool _isIdentPart(int c) => _isIdentStart(c) || (c >= 0x30 && c <= 0x39); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 735da6a7..22ac1bf7 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -86,8 +86,10 @@ class SqliteSqlExecutionDelegate extends SqlExecutionDelegate { } final cap = limit ?? kDefaultSqlResultMaxRows; + // Wrapped first, so the LIMIT lands on the outer query and the columns + // keep unique names (#1144). final effectiveSql = sqliteSqlIsReadOnlyQuery(sql) - ? injectSqlLimit(sql, cap) + ? injectSqlLimit(sqliteUniqueColumnsSql(sql), cap) : sql; final results = await conn.executeWithTimeout(effectiveSql, timeout: timeout); diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 1f1061b7..a103fa1b 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/sqlite_sql.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; @@ -178,7 +179,7 @@ class SqliteTableDataDelegate extends TableDataMutationDelegate { throw StateError('Not connected'); } - final rs = await conn.execute(sql); + final rs = await conn.execute(sqliteUniqueColumnsSql(sql)); final cols = []; if (rs.isNotEmpty) { cols.addAll(rs.first.keys); diff --git a/test/core/database/sqlite_unique_columns_test.dart b/test/core/database/sqlite_unique_columns_test.dart new file mode 100644 index 00000000..d404c209 --- /dev/null +++ b/test/core/database/sqlite_unique_columns_test.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/database/sqlite_sql.dart'; +import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; +import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; +import 'package:querya_desktop/core/mcp/mcp_sql_delegates.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _All implements McpAccessPolicy { + @override + Future canRead(ConnectionRow row) async => true; +} + +void main() { + group('sqliteUniqueColumnsSql', () { + test('wraps a SELECT so repeated names get suffixes', () { + expect( + sqliteUniqueColumnsSql('SELECT o.id, u.id FROM o JOIN u ON 1'), + 'SELECT * FROM (\nSELECT o.id, u.id FROM o JOIN u ON 1\n)', + ); + }); + + test('wraps a WITH query, and leaves comments and semicolons outside', () { + expect( + sqliteUniqueColumnsSql('-- c\nWITH x AS (SELECT 1) SELECT * FROM x; '), + startsWith('SELECT * FROM (\n-- c\nWITH x AS (SELECT 1) SELECT * FROM x\n'), + ); + }); + + test('keeps a trailing line comment inside the parentheses', () { + final wrapped = sqliteUniqueColumnsSql('SELECT 1, 1 -- note'); + expect(wrapped.endsWith('\n)'), isTrue); + }); + + test('leaves non-select statements and several statements unchanged', () { + for (final sql in [ + 'PRAGMA table_info(t)', + 'EXPLAIN QUERY PLAN SELECT 1', + 'VALUES (1, 1)', + 'UPDATE t SET a = 1', + 'SELECT 1; SELECT 2', + ]) { + expect(sqliteUniqueColumnsSql(sql), sql, reason: sql); + } + }); + }); + + group('duplicate column names on a real SQLite file (#1144)', () { + late Directory dir; + late McpQueryService service; + + setUpAll(() async { + sqfliteFfiInit(); + dir = await Directory.systemTemp.createTemp('querya_dup_cols_'); + final path = '${dir.path}/shop.db'; + final seed = SqliteConnection( + id: 1, name: 'seed', path: path, createIfMissing: true); + await seed.connect(); + await seed.execute( + 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'); + await seed.execute('CREATE TABLE orders (id INTEGER PRIMARY KEY, ' + 'user_id INTEGER REFERENCES users(id), total REAL)'); + await seed.execute("INSERT INTO users VALUES (2, 'bob')"); + await seed.execute('INSERT INTO orders VALUES (12, 2, 9.5)'); + await seed.disconnect(); + + service = McpQueryService( + createDelegate: createReadOnlyMcpDelegate, + access: _All(), + loadConnections: () async => [ + ConnectionRow( + id: 7, + type: 'sqlite', + name: 'Shop', + host: path, + createdAt: DateTime.utc(2026).toIso8601String(), + ), + ], + ); + }); + + tearDownAll(() async { + await SqliteService.instance.disconnectAll(); + await dir.delete(recursive: true); + }); + + test('two id columns come back as two columns with their own values', + () async { + final r = await service.runQuery(7, + 'SELECT o.id, u.id FROM orders o JOIN users u ON u.id = o.user_id'); + expect(r.columns, ['id', 'id:1']); + expect(r.rows.single, ['12', '2']); + }); + + test('SELECT 1, 1 keeps both columns', () async { + final r = await service.runQuery(7, 'SELECT 1, 1'); + expect(r.columns, ['1', '1:1']); + expect(r.rows.single, ['1', '1']); + }); + + test('o.*, u.* returns every column of both tables', () async { + final r = await service.runQuery(7, + 'SELECT o.*, u.* FROM orders o LEFT JOIN users u ON u.id = o.user_id'); + expect(r.columns, ['id', 'user_id', 'total', 'id:1', 'name']); + expect(r.rows.single, ['12', '2', '9.5', '2', 'bob']); + }); + + test('a WITH query with a repeated name is unchanged in its values', + () async { + final r = await service.runQuery(7, + 'WITH x AS (SELECT id FROM users) SELECT x.id, u.id FROM x JOIN users u ON u.id = x.id -- done'); + expect(r.columns, ['id', 'id:1']); + expect(r.rows.single, ['2', '2']); + }); + + test('results with unique names keep their names and order', () async { + final r = await service.runQuery(7, 'SELECT name, id FROM users ORDER BY id'); + expect(r.columns, ['name', 'id']); + expect(r.rows, [ + ['bob', '2'], + ]); + }); + }); +}