diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 81d9d76..6765332 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -21,6 +21,7 @@ import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -327,6 +328,7 @@ class _MysqlSqlWorkspaceState extends material.State { session.statusLine = null; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; + session.resultGridColumnMeta = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -393,22 +395,20 @@ class _MysqlSqlWorkspaceState extends material.State { } final target = SqlTableTargetExtractor.extract(userSql); - var pks = const []; - Map? types; + var gridSchema = SqlResultGridSchema.none; final schemaName = target?.schema ?? _poolDatabaseKey(); if (target != null && cols.isNotEmpty && schemaName.isNotEmpty) { - try { - final meta = await conn.getTableSchema( - database: schemaName, - table: target.tableName, - ); - pks = List.from(meta.primaryKeys); - types = columnDataTypesFromSchema(meta); - } catch (_) { - pks = const []; - types = null; - } + gridSchema = SqlResultGridSchema.fromLoad( + await loadTableViewSchema( + () => conn.getTableSchema( + database: schemaName, + table: target.tableName, + ), + ), + ); } + final pks = gridSchema.primaryKeys; + final editHint = gridSchema.editHint(cols); final canSave = sqlResultGridSaveEnabled( sql: userSql, resultColumns: cols, @@ -421,7 +421,8 @@ class _MysqlSqlWorkspaceState extends material.State { session.affectedRows = affected; session.lastExecutedSql = userSql; session.resultGridPrimaryKeys = canSave ? pks : const []; - session.resultGridColumnDataTypes = types; + session.resultGridColumnDataTypes = gridSchema.columnDataTypes; + session.resultGridColumnMeta = gridSchema.columnMeta; session.stagingBuffer?.dispose(); session.stagingBuffer = canSave ? DataGridStagingBuffer( @@ -435,9 +436,12 @@ class _MysqlSqlWorkspaceState extends material.State { ? 'OK. Rows affected: $affected.' : 'Command completed.'; } else { - session.statusLine = truncated - ? 'Showing first $cap row(s) (result capped).' - : '$n row(s).'; + session.statusLine = withEditHint( + truncated + ? 'Showing first $cap row(s) (result capped).' + : '$n row(s).', + editHint, + ); } session.running = false; }); @@ -512,6 +516,7 @@ class _MysqlSqlWorkspaceState extends material.State { schema: schemaName, primaryKeys: session.resultGridPrimaryKeys, columnDataTypes: session.resultGridColumnDataTypes, + columnMeta: session.resultGridColumnMeta, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index f0019da..23558f4 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -21,6 +21,7 @@ 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'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -454,6 +455,7 @@ class _PostgresSqlWorkspaceState extends material.State { session.statusLine = null; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; + session.resultGridColumnMeta = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -513,21 +515,19 @@ class _PostgresSqlWorkspaceState extends material.State { ); final target = SqlTableTargetExtractor.extract(userSql); - var pks = const []; - Map? types; + var gridSchema = SqlResultGridSchema.none; if (target != null && cols.isNotEmpty) { - try { - final meta = await conn.getTableSchema( - schema: target.schema ?? 'public', - table: target.tableName, - ); - pks = List.from(meta.primaryKeys); - types = columnDataTypesFromSchema(meta); - } catch (_) { - pks = const []; - types = null; - } + gridSchema = SqlResultGridSchema.fromLoad( + await loadTableViewSchema( + () => conn.getTableSchema( + schema: target.schema ?? 'public', + table: target.tableName, + ), + ), + ); } + final pks = gridSchema.primaryKeys; + final editHint = gridSchema.editHint(cols); final canSave = sqlResultGridSaveEnabled( sql: userSql, resultColumns: cols, @@ -540,7 +540,8 @@ class _PostgresSqlWorkspaceState extends material.State { session.affectedRows = result.affectedRows; session.lastExecutedSql = userSql; session.resultGridPrimaryKeys = canSave ? pks : const []; - session.resultGridColumnDataTypes = types; + session.resultGridColumnDataTypes = gridSchema.columnDataTypes; + session.resultGridColumnMeta = gridSchema.columnMeta; session.stagingBuffer?.dispose(); session.stagingBuffer = canSave ? DataGridStagingBuffer( @@ -554,9 +555,12 @@ class _PostgresSqlWorkspaceState extends material.State { 'Command completed. Rows affected: ${result.affectedRows}.'; } else { final truncated = result.length >= cap; - session.statusLine = truncated - ? 'Showing first $cap row(s) (result capped).' - : '${result.length} row(s).'; + session.statusLine = withEditHint( + truncated + ? 'Showing first $cap row(s) (result capped).' + : '${result.length} row(s).', + editHint, + ); } session.running = false; }); @@ -635,6 +639,7 @@ class _PostgresSqlWorkspaceState extends material.State { schema: target.schema ?? 'public', primaryKeys: session.resultGridPrimaryKeys, columnDataTypes: session.resultGridColumnDataTypes, + columnMeta: session.resultGridColumnMeta, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index c28bc53..a0367cb 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -19,6 +19,7 @@ import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -319,6 +320,7 @@ class _SqliteSqlWorkspaceState extends material.State { session.statusLine = null; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; + session.resultGridColumnMeta = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -368,18 +370,28 @@ class _SqliteSqlWorkspaceState extends material.State { final outRows = await convertResultRowsToStringsAdaptive(rawRows); final target = SqlTableTargetExtractor.extract(userSql); - var pks = const []; - Map? types; + var gridSchema = SqlResultGridSchema.none; if (target != null && cols.isNotEmpty) { + // Views have no rowid, so they never fall back to it. + var isView = false; try { - final meta = await conn.getTableSchema(table: target.tableName); - pks = List.from(meta.primaryKeys); - types = columnDataTypesFromSchema(meta); + final kind = await conn.execute( + 'SELECT type FROM sqlite_master WHERE name = ?', + [target.tableName], + ); + isView = kind.isNotEmpty && kind.first['type'] == 'view'; } catch (_) { - pks = const []; - types = null; + // Unknown kind: treat as a table (the schema load surfaces real errors). } + gridSchema = SqlResultGridSchema.fromLoad( + await loadTableViewSchema( + () => conn.getTableSchema(table: target.tableName), + ), + sqliteImplicitRowid: !isView, + ); } + final pks = gridSchema.primaryKeys; + final editHint = gridSchema.editHint(cols); final canSave = sqlResultGridSaveEnabled( sql: userSql, resultColumns: cols, @@ -392,7 +404,8 @@ class _SqliteSqlWorkspaceState extends material.State { session.affectedRows = null; session.lastExecutedSql = userSql; session.resultGridPrimaryKeys = canSave ? pks : const []; - session.resultGridColumnDataTypes = types; + session.resultGridColumnDataTypes = gridSchema.columnDataTypes; + session.resultGridColumnMeta = gridSchema.columnMeta; session.stagingBuffer?.dispose(); session.stagingBuffer = canSave ? DataGridStagingBuffer( @@ -404,9 +417,13 @@ class _SqliteSqlWorkspaceState extends material.State { if (cols.isEmpty && outRows.isEmpty) { session.statusLine = 'Command completed.'; } else if (truncated || (injectedLimit && results.length >= cap)) { - session.statusLine = 'Showing first $cap row(s) (result capped).'; + session.statusLine = withEditHint( + 'Showing first $cap row(s) (result capped).', + editHint, + ); } else { - session.statusLine = '${results.length} row(s).'; + session.statusLine = + withEditHint('${results.length} row(s).', editHint); } session.running = false; }); @@ -479,6 +496,7 @@ class _SqliteSqlWorkspaceState extends material.State { schema: target.schema, primaryKeys: session.resultGridPrimaryKeys, columnDataTypes: session.resultGridColumnDataTypes, + columnMeta: session.resultGridColumnMeta, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index 4ff7460..188be91 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/actions/sql_script_format.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; @@ -75,6 +76,10 @@ class SqlQueryTabSession { /// Column types from [getTableSchema] for DML literals, if resolved. Map? resultGridColumnDataTypes; + /// Column flags (generated / default / nullable) so INSERT can omit columns + /// the database fills in, same as Table Browser. + Map? resultGridColumnMeta; + void formatSql() { final next = formatSqlScript(controller.text); controller.value = material.TextEditingValue( diff --git a/lib/features/workspace/sql_result_grid_schema.dart b/lib/features/workspace/sql_result_grid_schema.dart new file mode 100644 index 0000000..3e9293e --- /dev/null +++ b/lib/features/workspace/sql_result_grid_schema.dart @@ -0,0 +1,87 @@ +import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; +import 'package:querya_desktop/features/workspace/table_view_staging.dart'; + +/// Schema facts the SQL result grid needs to make a single-table SELECT +/// editable, mirroring what Table Browser derives from `getTableSchema`. +class SqlResultGridSchema { + const SqlResultGridSchema({ + this.primaryKeys = const [], + this.columnDataTypes, + this.columnMeta, + this.schemaError, + this.needsRowidColumn = false, + }); + + /// No schema was looked up (not a simple single-table SELECT). + static const none = SqlResultGridSchema(); + + /// Builds the schema from a [loadTableViewSchema] outcome. + /// + /// With [sqliteImplicitRowid], a table without a declared PRIMARY KEY is keyed + /// by its implicit `rowid` (as in Table Browser); Save then needs `rowid` to be + /// one of the SELECTed columns. + factory SqlResultGridSchema.fromLoad( + TableViewSchemaLoad loaded, { + bool sqliteImplicitRowid = false, + }) { + final schema = loaded.schema; + if (schema == null) { + return SqlResultGridSchema(schemaError: loaded.error); + } + + var primaryKeys = List.from(schema.primaryKeys); + final types = columnDataTypesFromSchema(schema); + final meta = columnMetaFromSchema(schema); + + var needsRowid = false; + if (sqliteImplicitRowid) { + primaryKeys = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: primaryKeys, + isView: false, + ); + needsRowid = sqliteBrowseNeedsRowidColumn( + primaryKeys: primaryKeys, + isView: false, + ); + if (needsRowid && !meta.containsKey(kSqliteImplicitRowid)) { + types[kSqliteImplicitRowid] = sqliteImplicitRowidColumn.dataType; + meta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; + } + } + + return SqlResultGridSchema( + primaryKeys: primaryKeys, + columnDataTypes: types, + columnMeta: meta, + needsRowidColumn: needsRowid, + ); + } + + final List primaryKeys; + final Map? columnDataTypes; + final Map? columnMeta; + + /// `getTableSchema` failure, kept apart from a genuine "no primary key". + final Object? schemaError; + + /// SQLite table keyed only by implicit `rowid`. + final bool needsRowidColumn; + + /// Why Save is off, appended to the result status line. Null when nothing + /// needs saying (editable, or not a plain table SELECT). + String? editHint(List resultColumns) { + final error = schemaError; + if (error != null) { + return 'Cannot edit: schema unavailable. $error. Run the query again to retry.'; + } + if (needsRowidColumn && !resultColumns.contains(kSqliteImplicitRowid)) { + return 'To edit rows, include rowid in the SELECT (this table has no primary key).'; + } + return null; + } +} + +/// Appends [hint] to [status] as a second sentence. +String withEditHint(String status, String? hint) => + hint == null ? status : '$status $hint'; diff --git a/test/features/workspace/sql_result_grid_schema_test.dart b/test/features/workspace/sql_result_grid_schema_test.dart new file mode 100644 index 0000000..489c4a5 --- /dev/null +++ b/test/features/workspace/sql_result_grid_schema_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.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_connection.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/features/sqlite/sqlite_table_utils.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; +import 'package:querya_desktop/features/workspace/table_view_staging.dart'; + +void main() { + setUpAll(sqfliteFfiInit); + + const usersSchema = TableSchemaMeta( + tableName: 'users', + columns: [ + TableColumnMeta( + name: 'id', + dataType: 'integer', + isNullable: false, + isPrimaryKey: true, + hasServerDefault: true, + ), + TableColumnMeta(name: 'name', dataType: 'text'), + ], + primaryKeys: ['id'], + ); + + group('SqlResultGridSchema.fromLoad', () { + test('carries primary keys, types and column meta from the schema', () { + final schema = SqlResultGridSchema.fromLoad( + const TableViewSchemaLoad.ok(usersSchema), + ); + expect(schema.primaryKeys, ['id']); + expect(schema.columnDataTypes, {'id': 'integer', 'name': 'text'}); + expect(schema.columnMeta!['id']!.hasServerDefault, isTrue); + expect(schema.schemaError, isNull); + expect(schema.editHint(['id', 'name']), isNull); + }); + + test('a failed schema load is reported, not treated as "no primary key"', + () { + final schema = SqlResultGridSchema.fromLoad( + TableViewSchemaLoad.failed(StateError('permission denied')), + ); + expect(schema.primaryKeys, isEmpty); + expect(schema.columnMeta, isNull); + final hint = schema.editHint(['id']); + expect(hint, contains('schema unavailable')); + expect(hint, contains('permission denied')); + }); + + test('SqlResultGridSchema.none has nothing to say', () { + expect(SqlResultGridSchema.none.editHint(const ['a']), isNull); + expect(SqlResultGridSchema.none.primaryKeys, isEmpty); + }); + + test('implicit rowid keys a table without a declared primary key', () { + const noPk = TableSchemaMeta( + tableName: 't', + columns: [TableColumnMeta(name: 'name', dataType: 'TEXT')], + ); + final schema = SqlResultGridSchema.fromLoad( + const TableViewSchemaLoad.ok(noPk), + sqliteImplicitRowid: true, + ); + expect(schema.primaryKeys, [kSqliteImplicitRowid]); + expect(schema.needsRowidColumn, isTrue); + expect(schema.columnDataTypes![kSqliteImplicitRowid], 'INTEGER'); + expect(schema.columnMeta!.containsKey(kSqliteImplicitRowid), isTrue); + + expect(schema.editHint(['name']), contains('include rowid')); + expect(schema.editHint(['rowid', 'name']), isNull); + }); + + test('a declared primary key wins over rowid', () { + final schema = SqlResultGridSchema.fromLoad( + const TableViewSchemaLoad.ok(usersSchema), + sqliteImplicitRowid: true, + ); + expect(schema.primaryKeys, ['id']); + expect(schema.needsRowidColumn, isFalse); + expect(schema.editHint(['name']), isNull); + }); + + test('without the SQLite fallback a keyless table stays read-only', () { + const noPk = TableSchemaMeta(tableName: 't'); + final schema = + SqlResultGridSchema.fromLoad(const TableViewSchemaLoad.ok(noPk)); + expect(schema.primaryKeys, isEmpty); + expect(schema.needsRowidColumn, isFalse); + }); + }); + + test('withEditHint appends the hint as a second sentence', () { + expect(withEditHint('3 row(s).', null), '3 row(s).'); + expect(withEditHint('3 row(s).', 'Cannot edit.'), '3 row(s). Cannot edit.'); + }); + + group('SQL grid Save against a real SQLite table', () { + late SqliteConnection conn; + + setUp(() async { + conn = SqliteConnection( + id: 1, + name: 'mem', + path: inMemoryDatabasePath, + readOnly: false, + ); + await conn.connect(); + }); + + tearDown(() => conn.disconnect()); + + Future schemaFor(String table) async => + SqlResultGridSchema.fromLoad( + await loadTableViewSchema(() => conn.getTableSchema(table: table)), + sqliteImplicitRowid: true, + ); + + test('SELECT rowid, * on a keyless table can be saved, one duplicate row only', + () async { + await conn.execute('CREATE TABLE t (name TEXT)'); + await conn.execute("INSERT INTO t (name) VALUES ('dup'), ('dup')"); + const sql = 'SELECT rowid, * FROM t'; + final schema = await schemaFor('t'); + + final rs = await conn.execute(sql); + final cols = rs.first.keys.toList(); + expect(cols, ['rowid', 'name']); + expect( + sqlResultGridSaveEnabled( + sql: sql, + resultColumns: cols, + primaryKeys: schema.primaryKeys, + ), + isTrue, + ); + expect(schema.editHint(cols), isNull); + + final buffer = DataGridStagingBuffer( + columns: cols, + rows: [ + for (final r in rs) [for (final c in cols) '${r[c]}'], + ], + primaryKeys: schema.primaryKeys, + ); + addTearDown(buffer.dispose); + buffer.setCell(0, 1, 'changed'); + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 't', + primaryKeys: schema.primaryKeys, + columnDataTypes: schema.columnDataTypes, + columnMeta: schema.columnMeta, + ); + expect(plan.statements.single.sql, contains('WHERE "rowid" = 1')); + expect(await conn.executeAffected(plan.statements.single.sql), 1); + final after = await conn.execute('SELECT name FROM t ORDER BY rowid'); + expect(after.map((r) => r['name']), ['changed', 'dup']); + }); + + test('SELECT * on a keyless table stays read-only and explains why', + () async { + await conn.execute('CREATE TABLE t (name TEXT)'); + const sql = 'SELECT * FROM t'; + final schema = await schemaFor('t'); + + expect( + sqlResultGridSaveEnabled( + sql: sql, + resultColumns: const ['name'], + primaryKeys: schema.primaryKeys, + ), + isFalse, + ); + expect(schema.editHint(const ['name']), contains('include rowid')); + }); + + test('schema meta lets INSERT omit a blank generated key', () async { + await conn.execute('CREATE TABLE u (id INTEGER PRIMARY KEY, name TEXT)'); + final schema = await schemaFor('u'); + + DataGridStagingBuffer inserting() { + final b = DataGridStagingBuffer( + columns: const ['id', 'name'], + rows: const [], + primaryKeys: schema.primaryKeys, + )..addRow(['', 'Grace']); + addTearDown(b.dispose); + return b; + } + + final withMeta = inserting().generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 'u', + primaryKeys: schema.primaryKeys, + columnDataTypes: schema.columnDataTypes, + columnMeta: schema.columnMeta, + ); + expect(withMeta.statements.single.sql, isNot(contains('"id"'))); + expect(await conn.executeAffected(withMeta.statements.single.sql), 1); + + final withoutMeta = inserting().generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 'u', + primaryKeys: schema.primaryKeys, + columnDataTypes: schema.columnDataTypes, + ); + expect(withoutMeta.statements.single.sql, contains('"id"')); + }); + }); +}