diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index e60ee13..474d511 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_sql.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -309,6 +310,49 @@ class SqliteConnection { return rows.map((r) => r['name'] as String).toList(); } + /// Attempts to infer column names for a query (even when it yields zero rows) + /// using an ephemeral TEMP VIEW, or falling back to single-table schema. + Future> inferQueryColumns(String sql) async { + if (!isConnected || _db == null) return const []; + final trimmed = sqliteStripSqlComments(sql).trim(); + if (trimmed.isEmpty) return const []; + + // 1. Try temporary view probe in SQLite's in-memory temp schema. + const viewName = '_querya_zero_row_col_probe'; + try { + var cleanSql = trimmed; + while (cleanSql.endsWith(';')) { + cleanSql = cleanSql.substring(0, cleanSql.length - 1).trim(); + } + // Note: We execute on _db! directly to bypass readOnly restriction, + // as temp views only touch in-memory session temp schema. + await _db!.execute('CREATE TEMP VIEW IF NOT EXISTS $viewName AS $cleanSql'); + final rows = await _db!.rawQuery('PRAGMA table_info($viewName)'); + final cols = rows + .map((r) => r['name'] as String? ?? '') + .where((n) => n.isNotEmpty) + .toList(); + await _db!.execute('DROP VIEW IF EXISTS $viewName'); + if (cols.isNotEmpty) { + return cols; + } + } catch (_) { + try { + await _db!.execute('DROP VIEW IF EXISTS $viewName'); + } catch (_) {} + } + + // 2. Fallback: single-table target extraction + final target = SqlTableTargetExtractor.extract(trimmed); + if (target != null) { + try { + return await listColumnNames(table: target.tableName); + } catch (_) {} + } + + return const []; + } + /// Retrieves schema metadata and primary keys for [table]. Future getTableSchema({required String table}) async { final rows = await execute('PRAGMA table_info(${quoteIdentifier(table)})'); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index d349289..521194b 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -351,6 +351,8 @@ class _SqliteSqlWorkspaceState extends material.State { final cols = []; if (results.isNotEmpty) { cols.addAll(results.first.keys); + } else if (sqliteSqlIsReadOnlyQuery(userSql)) { + cols.addAll(await conn.inferQueryColumns(userSql)); } final truncated = results.length > cap; diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index be7e65e..0d08e6e 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -554,5 +554,25 @@ void main() { 'SQLite database is corrupt or not a database: /x', ); }); + + test('inferQueryColumns returns column names when query returns zero rows', () async { + final conn = SqliteConnection( + id: 99, + name: 'mem', + path: ':memory:', + ); + addTearDown(conn.disconnect); + await conn.connect(); + await conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);'); + + final colsAll = await conn.inferQueryColumns('SELECT * FROM users WHERE 1=0;'); + expect(colsAll, ['id', 'name', 'email']); + + final colsProjected = await conn.inferQueryColumns('SELECT id, name AS full_name FROM users WHERE id = -1;'); + expect(colsProjected, ['id', 'full_name']); + + final colsComputed = await conn.inferQueryColumns('SELECT 1 AS flag, COUNT(*) AS cnt WHERE 1=0;'); + expect(colsComputed, ['flag', 'cnt']); + }); }); }