diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c62686..1709654d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Planned + +- Hidden bugs pass 2 — data-loss / fail-open: [#867](https://github.com/QueryaHub/Querya-Desktop/issues/867)–[#873](https://github.com/QueryaHub/Querya-Desktop/issues/873) +- Algorithm / first paint / 120 Hz: [#874](https://github.com/QueryaHub/Querya-Desktop/issues/874)–[#879](https://github.com/QueryaHub/Querya-Desktop/issues/879) + +## [0.4.17] - 2026-09-20 + +Driver and grid correctness after 0.4.16: Table Browser schema vs missing PK, SQLite implicit `rowid`, Mongo write/filter/discard, and Postgres / MySQL / SQLite / Redis session, type, and Save fixes. + +### Fixed + +- **Table Browser schema load vs missing PK (#772)** — A failed `getTableSchema` (permissions, disconnect) is no longer shown as “Cannot edit: no primary key detected”. Status is “schema unavailable” plus the real error and Refresh retries schema. Editing stays off until schema loads. Genuine missing PKs still use the old copy. SQLite implicit `rowid` is applied only after a successful schema load. +- **SQLite implicit rowid Table Browser (#774)** — Tables with no declared PRIMARY KEY (`CREATE TABLE t (name TEXT)`) use implicit `rowid` as the DML key. Browse `SELECT` projects `"rowid", *` so Save can `UPDATE … WHERE rowid`. Status is no longer “no primary key”. `WITHOUT ROWID` tables keep their declared PK. +- **Mongo 0-match write (#776)** — `updateOne` / `replaceOne` / `deleteOne` throw when `nMatched` / `nRemoved` is 0 (wrong `_id` type, deleted doc). Inspector and JSON editor surface Save Failed instead of a success toast. An identical `$set` (`nModified == 0`) still counts as a match. +- **Mongo full-document Save (#778)** — JSON editor Save uses `replaceOne` (whole document, `_id` locked) instead of `$set`, so fields deleted in JSON — including nested keys — are removed on the server. +- **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. +- **Mongo JSON filter ObjectId / DateTime (#783)** — Document list filter parses Extended JSON (`$oid`, `$date`) and wraps a 24-character hex `_id` as `ObjectId`. A leftover string `_id` with zero matches shows how to write `{ "_id": { "$oid": "…" } }`. +- **Postgres open-transaction probe (#787)** — `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` stayed NULL (SQL-tab badge off; autocommit-off prepended a second `BEGIN`). The session tracks BEGIN/COMMIT/ROLLBACK and otherwise probes `pg_stat_activity.xact_start` (PG 9+). Implicit BEGIN when autocommit is off stays a separate `execute`. +- **Postgres Table Browser paging (#788)** — Browse stays on `PgSessionMode.readOnly`. `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `pg_class.reltuples` instead of a blocking `COUNT(*)` before first paint (stale estimates below the current page are ignored so Next still works). Save / REFRESH stay on `tableWrite`. +- **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. +- **SQLite WITH / PRAGMA (#797)** — `execute` classifies the first statement after comments: `WITH … INSERT` is a write (Dart read-only guard + `execute` instead of `rawQuery`). Assignment `PRAGMA name=value` is a write; `PRAGMA busy_timeout` stays a query. SQL workspace injects `LIMIT` only for read-only statements. +- **SQLite missing file (#798)** — Test Connection and opening a saved connection no longer create an empty `.db` when the path is a typo. Errors distinguish file not found, permission, and corrupt. New-connection Save still creates the file if it does not exist. +- **SQLite Table Browser paging (#799)** — Browse `SELECT` uses `ORDER BY` primary-key columns, or `rowid` when the table has implicit rowid. First paint skips blocking `COUNT(*)` (Next stays available when the page is full). SQL workspace has an optional statement timeout matching Postgres/MySQL. +- **SQLite BLOB / TEXT grid (#800)** — Table Browser and SQL results show BLOB as `X'hex'` (not `Uint8List.toString()`) and persist the same. Declared TEXT stays a quoted string even when it looks numeric (`00123`). +- **MySQL Table Browser session lock (#803)** — Browse stays on `MysqlSessionMode.readOnly`. Title-bar lock is passed into `MysqlTableView`: staging / Save stay off and Save does not acquire `tableWrite`. `SET SESSION TRANSACTION READ ONLY` is documented as a next-transaction hint (weaker than a read-only user; MariaDB vs MySQL 8). +- **MySQL ssl-mode (#805)** — `ssl-mode=prefer` enables TLS (same as `require`) instead of turning it off. `verify_ca` / `verify_identity` fail closed without `sslrootcert` and ask the driver to check the CA (and hostname for identity). Form Use SSL stays encrypt-only. +- **MySQL BOOLEAN / BIT / BLOB / JSON grid (#806)** — Schema uses `COLUMN_TYPE` so BOOLEAN is `tinyint(1)` (TRUE/FALSE on Save). BIT/BLOB/BINARY cells display as `0x` hex and persist as `X'…'`; JSON stays quoted text. The MySQL driver decodes charset-63 payloads as latin1 so invalid UTF-8 no longer throws. +- **MySQL Table Browser paging (#807)** — Browse `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `information_schema.TABLES.TABLE_ROWS` instead of a blocking `COUNT(*)` before first paint (stale estimates that are below the current page are ignored so Next still works). +- **Redis URL paste (#819)** — Paste `redis://` / `rediss://` stores the raw URI on `connectionString` (like Postgres/MySQL/Mongo), so `sslrootcert` / `sslcert` / `sslkey` reach `RedisConnection.connect`. +- **Redis binary bulk (#818)** — GET / SCAN / hash / list / set / zset decode bulk replies as bytes (`RedisParserBulkBinary`). Invalid UTF-8 is shown as hex / base64; Save as text is off so SET cannot write replacement characters. +- **Redis stream/unknown GET-SET (#817)** — Stream, module, and `unknown` keys are not opened with `GET` or saved with `SET`. Save stays on `string` only; `unknown` retries `TYPE` before treating the value as a string. +- **SQL toolbar overflow** — MySQL / Postgres Query + History + Execute wrap instead of overflowing at ~700px (widget tests treat RenderFlex overflow as failure). +- **Redis collection paging (#816)** — Hash / list / set / zset editors load the first 200 members (`HSCAN` / `LRANGE` / `SSCAN` / `ZRANGE`) with Load more, instead of `HGETALL` / `LRANGE 0 -1` / `SMEMBERS` / `ZRANGE 0 -1`. Keys with 10k+ members show a large-key warning. +- **Redis URI Test/sidebar (#815)** — Test Connection and the sidebar keyspace probe parse `redis://` / `rediss://` via `fromConnectionRow` (host, port, TLS, cert query params) instead of hitting localhost:6379. The probe keeps `id: -1` so it does not replace workspace sockets. +- **Redis DEL confirm (#814)** — Deleting a key (browser or editor) and removing a hash field / set member / zset member opens the same destructive-operation dialog as SQL. Cancel / Escape does not send `DEL` / `HDEL` / `SREM` / `ZREM`. +- **Mongo drop/delete confirm (#781)** — Drop database, drop collection, document Del, and editor Delete open the same destructive-operation dialog as SQL. Cancel / Escape does not call `dropDatabase` / `dropCollection` / `deleteOne`. +- **Redis title-bar read-only (#813)** — Session lock is passed into the key browser and editor. Save, DEL, HSET/HDEL, RPUSH, SADD/SREM, ZADD/ZREM, and TTL apply stay hidden; the explorer socket also refuses those writes and sends `READONLY` after AUTH when locked (ignored on standalone / older servers). +- **SQLite Table Browser read-only (#793)** — Connection-form Read only (`useSSL`) opens the file with `SQLITE_OPEN_READONLY` even for Save (`tableWrite`). Title-bar session lock is passed into `SqliteTableView`: staging / Save stay off and the grid does not acquire a writable handle. +- **MySQL SQL tx toolbar (#809)** — Begin / Commit / Rollback run `START TRANSACTION` / `COMMIT` / `ROLLBACK` on the SQL socket without replacing the editor buffer. The toolbar shows Transaction open/none. Table Browser Save stays on the `tableWrite` socket from #802 (no nested `START TRANSACTION`); opening a table still warns while SQL has an open transaction. +- **MySQL SQL-grid Save (#804)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `START TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open SQL transaction). +- **SQLite SQL-grid Save (#795)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open `BEGIN`). +- **Postgres SQL-grid Save (#786)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK present in the result (`getTableSchema`). JOIN, comma-`FROM`, subqueries, and VALUES stay read-only. DML `WHERE` uses the PK and `columnDataTypes` instead of every displayed column. +- **Redis Overview/Explorer sockets (#812)** — Overview INFO and Explorer SCAN/GET/`SELECT` use separate pooled sockets (`stats` vs `explorer`) so opening a DB no longer kills keep-alive Overview, and Home Refresh no longer steals Explorer. User Disconnect closes both roles. +- **MySQL Table Browser session (#802)** — Browse uses a read-only pool slot; Save uses a dedicated `tableWrite` socket so SQL `START TRANSACTION` / `SET` / `USE` do not leak into the grid. SQL-grid Save joins an already-open transaction instead of nesting `START TRANSACTION`. Opening a table on the same database (and Overview↔SQL) warns while SQL has an open transaction. +- **SQLite Table Browser session (#794)** — Browse uses a read-only `Database`; Save uses a dedicated `tableWrite` handle so SQL `BEGIN` / `ATTACH` do not leak into the grid (no nested `BEGIN`). SQL-grid Save joins an already-open transaction instead of starting another. Opening a table (and Overview↔SQL) warns while SQL has an open `BEGIN`. +- **Postgres Table Browser session (#785)** — Browse uses a read-only pool slot; Save and `REFRESH MATERIALIZED VIEW` use a dedicated `tableWrite` socket so they do not share the SQL editor’s TCP session. Statement-timeout `forceClose` on SQL no longer kills the grid. Opening a table while SQL has an open transaction on the same database warns, matching Overview↔SQL. +- **Mongo inspector Apply (#775)** — With **Save to DB** wired, **Apply** and **Ctrl/Cmd+Enter** persist through the same `$set` callback instead of only closing the dialog. SQL-grid Apply (no callback) still stages locally. +- **Mongo field codec (#777)** — Inspector Save keeps BSON type: strings that look like numbers stay strings; ObjectId, DateTime, Int64, and Decimal128 round-trip. `$set` of `_id` stays blocked. +- **Mongo document editor (#779)** — Documents encode as relaxed Extended JSON (`$oid`, `$date`, …) instead of `JsonEncoder` / `toString()`. Save decodes EJSON so the `_id` filter stays an `ObjectId`, not a string. +- **0-row DML Save (#773)** — Table Browser and SQL-grid Save fail (and roll back) if any statement matches 0 rows instead of toasting success from statement count. The staging buffer is kept. +- **Table Browser navigation (#771)** — Home, Close workspace, tree/object changes, and the matching Command Palette actions confirm before discarding staged grid or SQL edits. Cancel leaves the staging buffer registered. +- **Redis SET TTL (#811)** — Saving a string key uses `SET … KEEPTTL XX` (Redis 6+) so an existing expiry is not cleared. Older servers fall back to `TTL` then `SET … EX`. A key that already expired is not recreated. +- **MySQL SELECT cap (#808)** — SQL Workspace injects/clamps `LIMIT` like Postgres and drains leftover `rowsStream` rows instead of cancelling, so the session is ready for the next statement. Table Browser custom SQL gets the same LIMIT clamp so `execute` does not buffer an unbounded result. +- **Postgres Save (#784)** — Table Browser and SQL-grid Save run `BEGIN`, each DML statement, and `COMMIT` as separate extended-protocol executes (Parse cannot contain multiple commands). Autocommit-off SQL starts a transaction with its own `BEGIN` instead of concatenating `BEGIN;` onto the user query. +- **Mongo session auth (#780)** — After `scrubCredentials()`, find/update/list still authenticate from an in-memory session URI (getters stay null; nothing is written back). One `Db` is reused per database instead of open/close on every call. +- **Table Browser INSERT (#792, #810)** — Skip `GENERATED ALWAYS` / identity-always / `AUTO_INCREMENT` columns and omit empty cells that have a server default, so new rows do not send `id` or `''`. +- **SQLite WAL (#801)** — Open writable user databases with `PRAGMA journal_mode=WAL` (falls back if the FS cannot create the WAL file) and surface `SQLITE_BUSY` as a retryable error instead of a generic execute failure. + ## [0.4.16] - 2026-09-20 In-place editing in Table Browser (same DML staging as SQL Workspace), Command Palette and Quick Switcher, workspace chrome parity across drivers, connections-tree visual + FPS work, MongoDB per-field Save to DB, and a hardened in-app updater. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3878819f..57d488da 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,10 +1,25 @@ -# Pre-release checklist (release **0.4.16**) +# Pre-release checklist (release **0.4.17**) -Use this before tagging **`0.4.16`** or running the **Release** workflow. +Use this before tagging **`0.4.17`** or running the **Release** workflow. See [tags-and-releases.md](tags-and-releases.md), [CHANGELOG.md](../CHANGELOG.md). +Tracking: [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). Milestone [0.4.17](https://github.com/QueryaHub/Querya-Desktop/milestone/8). Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (does not block this cut). -## Product smoke (manual) — 0.4.16 +## Product smoke (manual) — 0.4.17 + +- [ ] **Table Browser schema vs PK (#772)** — failed `getTableSchema` shows “schema unavailable”, not “no primary key”; Refresh retries schema. +- [ ] **SQLite implicit rowid (#774)** — `CREATE TABLE t (name TEXT)` Table Browser can Save via `rowid`; `WITHOUT ROWID` stays on declared PK. +- [ ] **Mongo 0-match (#776)** — inspector/JSON Save with a wrong `_id` is Save Failed, not a success toast. +- [ ] **Mongo full-document Save (#778)** — JSON editor Save is `replaceOne`; deleting a nested key in JSON removes it on the server. +- [ ] **Mongo dirty editor Back (#782)** — dirty JSON + breadcrumb Back: discard dialog; Cancel keeps the editor. +- [ ] **Mongo JSON filter (#783)** — `{ "_id": { "$oid": "…" } }` and 24-char hex `_id` match ObjectId documents. +- [ ] **SQL-grid Save** — simple single-table `SELECT` with a PK can Save; JOIN / no-PK stays read-only (Postgres #786, SQLite #795, MySQL #804). +- [ ] **0-row DML (#773)** — Save that matches 0 rows fails and keeps the staging buffer. +- [ ] **Table Browser edit (regression)** — Postgres/MySQL/SQLite table with a PK: double-click cell, Save via DML preview. +- [ ] **Command Palette** — Ctrl/Cmd+P runs a command; Ctrl/Cmd+K jumps to a table. +- [ ] **Mongo field Save** — inspector `$set` still 0-match fails (#776); JSON editor is `replaceOne`. + +## Regression smoke (prior releases) - [ ] Fresh profile / empty state: create one connection per supported type (PostgreSQL, MySQL, Redis, MongoDB, SQLite). - [ ] Reopen the app: connections still appear; **connect** succeeds (secrets migrated or loaded from secure store). @@ -16,17 +31,6 @@ Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desk - [ ] **Extension table view** — open a sandboxed driver table (or fixture); toolbar filter + export present. - [ ] **Updater** — Check for Updates / badge sees Latest Release channel correctly after tag. - [ ] **Fluid shell** — tab strip sliding pill; dialog/dropdown fade-slide; Motion Off snaps (see also [perf-baseline.md](perf-baseline.md) Fluid §). -- [ ] **Table Browser edit** — Postgres/MySQL/SQLite table with a PK: double-click cell, Save via DML preview; view / no-PK stays read-only. -- [ ] **Command Palette** — Ctrl/Cmd+P runs a command; Ctrl/Cmd+K jumps to a table. -- [ ] **Mongo field Save** — expand a document card, tap a non-`_id` field, Save to DB, card reloads. - -## Regression smoke (prior releases) - -- [ ] Fresh profile / empty state: create one connection per supported type (PostgreSQL, MySQL, Redis, MongoDB). -- [ ] Reopen the app: connections still appear; **connect** succeeds (secrets migrated or loaded from secure store). -- [ ] Remove a connection: it disappears and reconnect is impossible without re-entering credentials. -- [ ] **Connection → New Database Connection** from the menu saves and shows in the tree. -- [ ] **Driver Manager** shows only built-in drivers (no misleading JDBC requirement). ## Custom themes (manual QA) @@ -60,23 +64,24 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support - [ ] **OS Reduced Motion** — enable reduced motion in OS settings. The app should automatically disable animations (acting as Off) regardless of in-app Full/Reduced settings (OS setting wins). - [ ] **Hz diagnostics** — start the app with `--dart-define=QUERYA_REFRESH_OVERLAY=true`. The overlay should display the monitor refresh rate (**Linux: query-only** — compositor decides Hz; see [motion-and-high-refresh.md](motion-and-high-refresh.md)). - [ ] **High refresh rate smoothness** — verify dialog fade-slide, dropdown show, and tree expand/collapse look smooth at high-Hz (90/120/144 Hz) without jank. +- [ ] Optional: full DevTools pass @ 120 Hz ([#739](https://github.com/QueryaHub/Querya-Desktop/issues/739)). ## Automated -- [x] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). -- [x] `flutter test` — all green. +- [ ] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). +- [ ] `flutter test` — all green. - [ ] CI **Flutter version** in `.github/workflows/*.yml` matches the toolchain you validated (bump intentionally when upgrading stable). ## Versioning and release -- [x] `pubspec.yaml` on the release branch is **`0.4.16+1`**. -- [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.17+…** placeholder (do not ship binaries as 0.4.17). -- [ ] **Tag** `0.4.16` is placed on the **main merge commit that includes `0.4.16+1`** (not the auto-bump commit). +- [x] `pubspec.yaml` on the 0.4.17 track is **`0.4.17+1`**. +- [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.18+…** placeholder (do not ship binaries as 0.4.18). +- [ ] **Tag** `0.4.17` is placed on the **main merge commit that includes `0.4.17+1`** (not the auto-bump commit). - [ ] Run the **Release** workflow via that tag (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*.rpm`, `*.flatpak`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. ## Docs -- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.16]`** section for the release (CI copies it into the GitHub Release body). +- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.17]`** section for the release (CI copies it into the GitHub Release body). - [x] [security.md](security.md) still matches behavior if storage changed. -- [x] [roadmap.md](roadmap.md) marks 0.4.16 as this cut and 0.5.0 as next. +- [x] [roadmap.md](roadmap.md) marks 0.4.17 as this cut and 0.5.0 as next. diff --git a/docs/roadmap.md b/docs/roadmap.md index 399cdc97..57fd533f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,9 +2,9 @@ Living document for planned work. Not a commitment order; adjust as priorities change. -**GitHub Latest Release:** [0.4.15](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.15) (2026-09-06). -**This cut:** **0.4.16** — Table Browser in-place editing, Command Palette, workspace/tree parity, Mongo field Save to DB — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. -**Next product release:** **0.5.0** — live Marketplace download and install — see below. +**GitHub Latest Release:** [0.4.16](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.16) (2026-09-20). +**This cut:** **0.4.17** — driver/grid correctness after 0.4.16 (schema vs PK, SQLite `rowid`, Mongo writes, SQL sessions/types) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.17]`, tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). +**Next product release:** **0.5.0** — live Marketplace download and install — see below. Hidden bugs pass 2 (#867–#873) and algorithm/perf (#874–#879) slip to the next patch. ## Theme system @@ -29,7 +29,9 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **0.4.11-a:** changelog prepared; GitHub tag mistargeted onto `0.4.11` — do not treat as a shipped patch. - **Shipped in 0.4.11-b:** security (#395–#402), Linux `.rpm` / Flatpak / AUR (#386), perf (#414), UI reliability (#445), code-review fixes (#463) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-b]`. - **Shipped in 0.4.13–0.4.15:** interactive Data Grid, fluid motion, multi-tab SQL, preferences/keymap — [CHANGELOG.md](../CHANGELOG.md). -- **0.4.16 (this cut):** Table Browser in-place DML, Command Palette + Quick Switcher, workspace chrome / connections-tree parity, Mongo per-field Save to DB, updater hardening — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. Manual 120 Hz DevTools sign-off remains [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739). +- **Shipped in 0.4.16:** Table Browser in-place DML, Command Palette + Quick Switcher, workspace chrome / connections-tree parity, Mongo per-field Save to DB, updater hardening — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. +- **0.4.17 (this cut):** driver/grid correctness after 0.4.16 — schema vs PK (#772), SQLite `rowid` (#774), Mongo writes/filter/discard (#776/#778/#782/#783), Postgres/MySQL/SQLite/Redis session and type round-trips — [CHANGELOG.md](../CHANGELOG.md) `[0.4.17]`, tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). +- **Next patch:** hidden bugs pass 2 ([#867](https://github.com/QueryaHub/Querya-Desktop/issues/867)–[#873](https://github.com/QueryaHub/Querya-Desktop/issues/873)) and algorithm/perf ([#874](https://github.com/QueryaHub/Querya-Desktop/issues/874)–[#879](https://github.com/QueryaHub/Querya-Desktop/issues/879)). Manual 120 Hz DevTools sign-off remains [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (optional). - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 25486934..0ca1283a 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -1,4 +1,5 @@ -/// Categorization of destructive SQL operations that can alter or destroy schema/data. +/// Categorization of destructive SQL / Mongo / Redis operations that can +/// alter or destroy schema/data. enum DestructiveSqlType { dropDatabase, dropSchema, @@ -6,7 +7,13 @@ enum DestructiveSqlType { dropView, dropMaterializedView, truncateTable, - unconditionalDelete; + unconditionalDelete, + dropCollection, + deleteDocument, + redisDel, + redisHdel, + redisSrem, + redisZrem; String get label => switch (this) { DestructiveSqlType.dropDatabase => 'DROP DATABASE', @@ -16,6 +23,12 @@ enum DestructiveSqlType { DestructiveSqlType.dropMaterializedView => 'DROP MATERIALIZED VIEW', DestructiveSqlType.truncateTable => 'TRUNCATE TABLE', DestructiveSqlType.unconditionalDelete => 'UNCONDITIONAL DELETE', + DestructiveSqlType.dropCollection => 'DROP COLLECTION', + DestructiveSqlType.deleteDocument => 'DELETE DOCUMENT', + DestructiveSqlType.redisDel => 'DEL', + DestructiveSqlType.redisHdel => 'HDEL', + DestructiveSqlType.redisSrem => 'SREM', + DestructiveSqlType.redisZrem => 'ZREM', }; String get riskLevel => switch (this) { @@ -24,6 +37,12 @@ enum DestructiveSqlType { DestructiveSqlType.dropTable => 'HIGH', DestructiveSqlType.truncateTable => 'HIGH', DestructiveSqlType.unconditionalDelete => 'HIGH', + DestructiveSqlType.dropCollection => 'HIGH', + DestructiveSqlType.deleteDocument => 'HIGH', + DestructiveSqlType.redisDel => 'HIGH', + DestructiveSqlType.redisHdel => 'HIGH', + DestructiveSqlType.redisSrem => 'HIGH', + DestructiveSqlType.redisZrem => 'HIGH', DestructiveSqlType.dropMaterializedView => 'MEDIUM', DestructiveSqlType.dropView => 'MEDIUM', }; @@ -48,14 +67,25 @@ class DestructiveSqlOperation { 'Permanently drops schema "$targetName" and all contained tables.', DestructiveSqlType.dropTable => 'Permanently drops table structure and all data in "$targetName".', - DestructiveSqlType.dropView => - 'Drops view "$targetName".', + DestructiveSqlType.dropView => 'Drops view "$targetName".', DestructiveSqlType.dropMaterializedView => 'Drops materialized view "$targetName".', DestructiveSqlType.truncateTable => 'Quickly deletes all rows from table "$targetName" without transaction rollbacks in some engines.', DestructiveSqlType.unconditionalDelete => 'Deletes all rows from table "$targetName" (no WHERE clause detected).', + DestructiveSqlType.dropCollection => + 'Permanently drops collection "$targetName" and all documents in it.', + DestructiveSqlType.deleteDocument => + 'Permanently deletes document "$targetName". This cannot be undone.', + DestructiveSqlType.redisDel => + 'Permanently deletes Redis key "$targetName". This cannot be undone.', + DestructiveSqlType.redisHdel => + 'Permanently removes hash field "$targetName".', + DestructiveSqlType.redisSrem => + 'Permanently removes set member "$targetName".', + DestructiveSqlType.redisZrem => + 'Permanently removes sorted-set member "$targetName".', }; } @@ -72,7 +102,9 @@ class DestructiveSqlInspectionResult { /// Returns highest risk level present ('CRITICAL', 'HIGH', 'MEDIUM', or 'NONE'). String get maxRiskLevel { if (operations.isEmpty) return 'NONE'; - if (operations.any((o) => o.type.riskLevel == 'CRITICAL')) return 'CRITICAL'; + if (operations.any((o) => o.type.riskLevel == 'CRITICAL')) { + return 'CRITICAL'; + } if (operations.any((o) => o.type.riskLevel == 'HIGH')) return 'HIGH'; return 'MEDIUM'; } @@ -151,7 +183,8 @@ abstract final class DestructiveSqlDetector { // 3. Dollar quotes in PostgreSQL: $$ or $tag$ if (sql[i] == '\$') { - final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + final match = + RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); if (match != null) { final tag = match.group(0)!; i += tag.length; @@ -231,7 +264,8 @@ abstract final class DestructiveSqlDetector { // Dollar quotes if (sql[i] == '\$') { - final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + final match = + RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); if (match != null) { final tag = match.group(0)!; current.write(tag); @@ -339,7 +373,8 @@ abstract final class DestructiveSqlDetector { if (dropMatViewMatch != null) { final schema = dropMatViewMatch.group(1); final view = dropMatViewMatch.group(2) ?? 'view'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.dropMaterializedView, @@ -355,7 +390,8 @@ abstract final class DestructiveSqlDetector { if (dropViewMatch != null) { final schema = dropViewMatch.group(1); final view = dropViewMatch.group(2) ?? 'view'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.dropView, @@ -371,7 +407,8 @@ abstract final class DestructiveSqlDetector { if (dropTableMatch != null) { final schema = dropTableMatch.group(1); final table = dropTableMatch.group(2) ?? 'table'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.dropTable, @@ -387,7 +424,8 @@ abstract final class DestructiveSqlDetector { if (truncateMatch != null) { final schema = truncateMatch.group(1); final table = truncateMatch.group(2) ?? 'table'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.truncateTable, @@ -401,11 +439,13 @@ abstract final class DestructiveSqlDetector { // 7. DELETE FROM table without WHERE final deleteMatch = _deleteRegex.firstMatch(sanitized); if (deleteMatch != null) { - final hasWhere = RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(sanitized); + final hasWhere = + RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(sanitized); if (!hasWhere) { final schema = deleteMatch.group(1); final table = deleteMatch.group(2) ?? 'table'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.unconditionalDelete, diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 1e5fb26c..c49b0e12 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -32,6 +32,13 @@ class MongoConnection { final String? replicaSet; String? _connectionString; + /// Handshake URI for this live session (includes auth). Not persisted; not + /// exposed via [password] / [connectionString] after [scrubCredentials]. + String? _sessionUri; + + final Map _openedDbs = {}; + final Map> _openingDbs = {}; + String? get password => _password; String? get connectionString => _connectionString; @@ -39,7 +46,11 @@ class MongoConnection { bool _isConnected = false; /// Scrubs sensitive in-memory credentials once the network handshake completes. + /// + /// Getters [password] and [connectionString] become null. The live session + /// still authenticates via [_sessionUri] (in-memory only, never written back). void scrubCredentials() { + _sessionUri ??= buildConnectionUri(); _password = null; _connectionString = null; } @@ -50,6 +61,12 @@ class MongoConnection { if (effectiveConnStr != null && effectiveConnStr.isNotEmpty) { return effectiveConnStr; } + if (pass == null && + _sessionUri != null && + _sessionUri!.isNotEmpty && + (_password == null || _password!.isEmpty)) { + return _sessionUri!; + } final buffer = StringBuffer('mongodb://'); @@ -153,7 +170,12 @@ class MongoConnection { ); _db = await Db.create(uri); await _db!.open(); + _sessionUri = uri; _isConnected = true; + final defaultName = _databaseNameFromUri(uri); + if (defaultName != null && defaultName.isNotEmpty) { + _openedDbs[defaultName] = _db!; + } scrubCredentials(); } catch (e) { _isConnected = false; @@ -193,15 +215,66 @@ class MongoConnection { /// Disconnects from MongoDB server. Future disconnect() async { _isConnected = false; - final db = _db; + _sessionUri = null; + _openingDbs.clear(); + final toClose = { + ..._openedDbs.values, + if (_db != null) _db!, + }; + _openedDbs.clear(); _db = null; - try { - await db?.close(); - } catch (e) { - debugPrint('MongoConnection.disconnect: $e'); + for (final db in toClose) { + try { + await db.close(); + } catch (e) { + debugPrint('MongoConnection.disconnect: $e'); + } } } + /// Opens (or reuses) a [Db] for [databaseName] on this live session. + /// + /// Auth comes from [_sessionUri], not from the scrubbed [password] getter. + Future openDatabase(String databaseName) async { + if (!isConnected) { + throw StateError('Not connected to MongoDB'); + } + final existing = _openedDbs[databaseName]; + if (existing != null && existing.isConnected) { + return existing; + } + if (existing != null) { + _openedDbs.remove(databaseName); + try { + await existing.close(); + } catch (_) {} + } + return _openingDbs.putIfAbsent(databaseName, () async { + try { + final dbUri = buildUriForDatabase(databaseName); + final db = await Db.create(dbUri); + try { + await db.open(); + } catch (_) { + try { + await db.close(); + } catch (_) {} + rethrow; + } + _openedDbs[databaseName] = db; + return db; + } finally { + _openingDbs.remove(databaseName); + } + }); + } + + static String? _databaseNameFromUri(String uri) { + final path = Uri.tryParse(uri)?.path.replaceFirst(RegExp(r'^/'), '') ?? ''; + if (path.isEmpty) return null; + return path.split('/').first; + } + /// Checks if connection is active. bool get isConnected => _isConnected && _db != null && _db!.isConnected; @@ -215,22 +288,15 @@ class MongoConnection { } try { - // Switch to admin database to list all databases - final adminUri = buildUriForDatabase('admin'); - final adminDb = await Db.create(adminUri); - await adminDb.open(); - try { - final result = await adminDb.runCommand({'listDatabases': 1}); - final databases = result['databases'] as List?; - if (databases == null) return []; - - return databases - .map((db) => (db as Map)['name'] as String) - .where((name) => name.isNotEmpty) - .toList(); - } finally { - await adminDb.close(); - } + final adminDb = await openDatabase('admin'); + final result = await adminDb.runCommand({'listDatabases': 1}); + final databases = result['databases'] as List?; + if (databases == null) return []; + + return databases + .map((db) => (db as Map)['name'] as String) + .where((name) => name.isNotEmpty) + .toList(); } catch (e) { rethrow; } @@ -243,16 +309,9 @@ class MongoConnection { } try { - // Create a new Db connection to the specified database - final dbUri = buildUriForDatabase(databaseName); - final db = await Db.create(dbUri); - await db.open(); - try { - final collections = await db.getCollectionNames(); - return collections.whereType().toList(); - } finally { - await db.close(); - } + final db = await openDatabase(databaseName); + final collections = await db.getCollectionNames(); + return collections.whereType().toList(); } catch (e) { rethrow; } @@ -279,13 +338,21 @@ class MongoConnection { } try { - final dbUri = buildUriForDatabase(databaseName); - final db = await Db.create(dbUri); - await db.open(); + final db = await openDatabase(databaseName); + await db.drop(); + _openedDbs.remove(databaseName); try { - await db.drop(); - } finally { await db.close(); + } catch (_) {} + if (identical(_db, db)) { + _db = null; + for (final other in _openedDbs.values) { + if (other.isConnected) { + _db = other; + break; + } + } + _isConnected = _db != null; } } catch (e) { rethrow; diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 631af8d1..85f89a45 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -3,6 +3,31 @@ import 'package:mongo_dart/mongo_dart.dart'; import '../storage/local_db.dart'; import 'mongodb_connection.dart'; +/// Throws if a single-document write matched nothing (wrong `_id` type, +/// concurrent delete). Same class of lie as 0-row SQL DML. +/// +/// [nModified] is ignored: an identical `$set` still matched the document. +void expectMongoDocumentMatched(int matched, {required String operation}) { + if (matched >= 1) return; + throw StateError( + '$operation failed: matched 0 documents. ' + 'The document may have been deleted or the _id type does not match.', + ); +} + +void _throwIfMongoWriteFailed( + WriteResult result, { + required String operation, + required int matched, +}) { + if (result.hasWriteErrors) { + throw StateError( + '$operation failed: ${result.writeError?.errmsg ?? 'write error'}', + ); + } + expectMongoDocumentMatched(matched, operation: operation); +} + /// Service for managing MongoDB connections. class MongoService { MongoService._(); @@ -173,7 +198,8 @@ class MongoService { }); } - /// Opens a temporary [Db] for the given [database], runs [action], then closes. + /// Reuses a pooled [Db] for [database] on the live session (auth via the + /// in-memory session URI, not the scrubbed password getter). Future _withDb( MongoConnection connection, String database, @@ -182,14 +208,8 @@ class MongoService { if (!connection.isConnected) { throw StateError('Not connected to MongoDB'); } - final dbUri = connection.buildUriForDatabase(database); - final db = await Db.create(dbUri); - await db.open(); - try { - return await action(db); - } finally { - await db.close(); - } + final db = await connection.openDatabase(database); + return await action(db); } /// Returns the document count for a collection (with optional filter). @@ -236,7 +256,7 @@ class MongoService { }); } - /// Updates a single document matched by [filter]. + /// Updates a single document matched by [filter] (`$set` / `$unset` / …). Future updateDocument( MongoConnection connection, String database, @@ -246,7 +266,31 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.updateOne(filter, update); + final result = await coll.updateOne(filter, update); + _throwIfMongoWriteFailed( + result, + operation: 'updateOne', + matched: result.nMatched, + ); + }); + } + + /// Replaces a single document matched by [filter] (full-document Save). + Future replaceDocument( + MongoConnection connection, + String database, + String collection, + Map filter, + Map replacement, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + final result = await coll.replaceOne(filter, replacement); + _throwIfMongoWriteFailed( + result, + operation: 'replaceOne', + matched: result.nMatched, + ); }); } @@ -259,7 +303,12 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.deleteOne(filter); + final result = await coll.deleteOne(filter); + _throwIfMongoWriteFailed( + result, + operation: 'deleteOne', + matched: result.nRemoved, + ); }); } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index bd0782bd..16c905e4 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -2,11 +2,67 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +/// TLS policy from `ssl-mode` / `sslmode` / `ssl` on a MySQL URI. +enum MysqlSslMode { + disable, + encrypt, + verifyCa, + verifyIdentity, +} + +extension MysqlSslModeX on MysqlSslMode { + bool get secure => this != MysqlSslMode.disable; + bool get verifyCertificates => + this == MysqlSslMode.verifyCa || this == MysqlSslMode.verifyIdentity; + bool get verifyIdentity => this == MysqlSslMode.verifyIdentity; +} + +/// Parses Connector/J-style `ssl-mode` (`prefer` is TLS on, not disable). +MysqlSslMode parseMysqlSslMode(String? raw, {required bool fallbackSsl}) { + if (raw == null || raw.trim().isEmpty) { + return fallbackSsl ? MysqlSslMode.encrypt : MysqlSslMode.disable; + } + switch (raw.toLowerCase().replaceAll('-', '_')) { + case 'false': + case '0': + case 'disable': + case 'disabled': + return MysqlSslMode.disable; + case 'prefer': + case 'preferred': + case 'require': + case 'required': + case 'true': + case '1': + case 'enabled': + return MysqlSslMode.encrypt; + case 'verify_ca': + return MysqlSslMode.verifyCa; + case 'verify_identity': + case 'verify_full': + return MysqlSslMode.verifyIdentity; + default: + return fallbackSsl ? MysqlSslMode.encrypt : MysqlSslMode.disable; + } +} + +/// `verify_ca` / `verify_identity` fail closed without `sslrootcert`. +void validateMysqlSslMode(MysqlSslMode mode, SslCertificatePaths paths) { + if (!mode.verifyCertificates) return; + final ca = paths.rootCert?.trim() ?? ''; + if (ca.isEmpty) { + final label = + mode == MysqlSslMode.verifyIdentity ? 'verify_identity' : 'verify_ca'; + throw ArgumentError('ssl-mode=$label requires sslrootcert'); + } +} + /// Replaces the database in a `mysql://` / `mariadb://` URI (path or `database=`). String replaceDatabaseInMysqlConnectionString( String connectionString, @@ -78,6 +134,7 @@ class MysqlConnection { MySQLConnection? _conn; bool _isConnected = false; + bool _inTransaction = false; bool get isConnected => _isConnected && _conn != null; @@ -102,7 +159,8 @@ class MysqlConnection { var effectiveConnectionString = _connectionString; if ((effectivePassword == null || effectivePassword.isEmpty) && - (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + (effectiveConnectionString == null || + effectiveConnectionString.isEmpty) && id > 0) { try { final secrets = await ConnectionSecretsStore.readForConnection(id); @@ -124,19 +182,28 @@ class MysqlConnection { : effectiveConnectionString!.trim(); final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); final sslPaths = extractSslCertificatePathsFromString(uriStr); + var sslMode = parsed.sslMode; + if (sslPaths.hasAny && sslMode == MysqlSslMode.disable) { + sslMode = MysqlSslMode.encrypt; + } + validateMysqlSslMode(sslMode, sslPaths); final securityContext = buildSecurityContext(sslPaths); + final host = parsed.host; _conn = await MySQLConnection.createConnection( - host: parsed.host, + host: host, port: parsed.port, userName: parsed.userName, password: parsed.password, - secure: parsed.secure || sslPaths.hasAny, + secure: sslMode.secure, databaseName: parsed.databaseName, securityContext: securityContext, + sslVerifyCertificates: sslMode.verifyCertificates, + sslServerName: sslMode.verifyIdentity && host is String ? host : null, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { - final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); + final sslPaths = + extractSslCertificatePathsFromString(effectiveConnectionString); final securityContext = buildSecurityContext(sslPaths); _conn = await MySQLConnection.createConnection( host: host, @@ -165,7 +232,7 @@ class MysqlConnection { String userName, String password, String? databaseName, - bool secure, + MysqlSslMode sslMode, }) _parseMysqlUri(String raw, {required bool fallbackSsl}) { final uri = Uri.parse(raw); if (uri.scheme != 'mysql' && uri.scheme != 'mariadb') { @@ -194,22 +261,16 @@ class MysqlConnection { db ??= uri.queryParameters['database']; final q = uri.queryParameters; - bool secure = fallbackSsl; - final ssl = (q['ssl-mode'] ?? q['sslmode'] ?? q['ssl'])?.toLowerCase(); - if (ssl == 'false' || - ssl == '0' || - ssl == 'disable' || - ssl == 'disabled' || - ssl == 'prefer') { - secure = false; - } - if (ssl == 'require' || ssl == 'verify_ca' || ssl == 'verify_identity') { - secure = true; - } + var sslMode = parseMysqlSslMode( + q['ssl-mode'] ?? q['sslmode'] ?? q['ssl'], + fallbackSsl: fallbackSsl, + ); if (q.containsKey(kSslRootCertParam) || q.containsKey(kSslCertParam) || q.containsKey(kSslKeyParam)) { - secure = true; + if (sslMode == MysqlSslMode.disable) { + sslMode = MysqlSslMode.encrypt; + } } return ( @@ -218,21 +279,33 @@ class MysqlConnection { userName: userName, password: password, databaseName: db, - secure: secure, + sslMode: sslMode, ); } + /// Parsed `ssl-mode` for [connectionString] (tests). + @visibleForTesting + static MysqlSslMode sslModeFromConnectionString( + String connectionString, { + bool fallbackSsl = true, + }) { + return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl).sslMode; + } + /// Whether [connectionString] implies a TLS session (including cert query params). @visibleForTesting static bool connectionStringRequiresSsl( String connectionString, { bool fallbackSsl = true, }) { - return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl).secure; + return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl) + .sslMode + .secure; } Future disconnect() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; try { @@ -248,6 +321,7 @@ class MysqlConnection { /// while a query is in progress. Future forceClose() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; if (c == null) return; @@ -260,16 +334,27 @@ class MysqlConnection { } } - /// Session hint for read-only browsing (MySQL 8+ / MariaDB — semantics differ from PostgreSQL). + /// Session hint for Table Browser / tree (`MysqlSessionMode.readOnly`). + /// + /// Issues `SET SESSION TRANSACTION READ ONLY` (or `READ WRITE`). This is the + /// **next-transaction** default, not PostgreSQL `default_transaction_read_only`. + /// + /// On MySQL 8 with autocommit, each statement is its own transaction, so the + /// hint applies — but it is weaker than a dedicated read-only user and does + /// not block `SET SESSION TRANSACTION READ WRITE` later on the same socket. + /// MariaDB accepts the same syntax; enforcement still depends on account + /// privileges. Browse stays on a read-only pool slot; Save uses `tableWrite`. Future setSessionReadOnly(bool readOnly) async { if (!isConnected || _conn == null) return; - if (readOnly) { - await execute('SET SESSION TRANSACTION READ ONLY'); - } else { - await execute('SET SESSION TRANSACTION READ WRITE'); - } + await execute(sessionTransactionAccessModeSql(readOnly)); } + /// `SET SESSION TRANSACTION READ ONLY` / `READ WRITE`. + @visibleForTesting + static String sessionTransactionAccessModeSql(bool readOnly) => readOnly + ? 'SET SESSION TRANSACTION READ ONLY' + : 'SET SESSION TRANSACTION READ WRITE'; + Future testConnection() async { try { await connect(); @@ -295,13 +380,58 @@ class MysqlConnection { throw StateError('Not connected to MySQL'); } try { - return await _conn!.execute(sql, params, iterable); + final rs = await _conn!.execute(sql, params, iterable); + _noteTransactionSql(sql); + return rs; } on TimeoutException { unawaited(forceClose()); rethrow; } } + /// Whether this session has an open `START TRANSACTION` / `BEGIN`. + Future inOpenTransaction() async { + if (!isConnected) return null; + return _inTransaction; + } + + /// Runs [body] inside a transaction. If the session already has one, DML + /// joins it instead of nesting `START TRANSACTION`. + Future runInTransaction(Future Function() body) async { + final join = _inTransaction; + if (!join) { + await execute('START TRANSACTION'); + } + try { + await body(); + if (!join) await execute('COMMIT'); + } catch (e) { + if (!join) { + try { + await execute('ROLLBACK'); + } catch (_) {} + } + rethrow; + } + } + + void _noteTransactionSql(String sql) { + final sqlLower = sql.trim().toLowerCase(); + if (sqlLower.startsWith('start transaction') || + sqlLower.startsWith('begin')) { + _inTransaction = true; + return; + } + if (sqlLower.startsWith('commit')) { + _inTransaction = false; + return; + } + if (sqlLower.startsWith('rollback') && + !RegExp(r'^rollback\s+to\b').hasMatch(sqlLower)) { + _inTransaction = false; + } + } + /// Runs [execute] with an application-level [timeout] (driver limits still apply). Future executeWithTimeout( String sql, { @@ -382,7 +512,7 @@ class MysqlConnection { throw StateError('Not connected to MySQL'); } final colsRs = await execute( - 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT ' + 'SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA ' 'FROM information_schema.COLUMNS ' 'WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table ' 'ORDER BY ORDINAL_POSITION', @@ -408,11 +538,21 @@ class MysqlConnection { for (final r in colsRs.rows) { final name = r.colByName('COLUMN_NAME') ?? ''; - final dataType = r.colByName('DATA_TYPE') ?? ''; - final isNullable = (r.colByName('IS_NULLABLE') ?? 'YES').toUpperCase() == 'YES'; + final dataType = mysqlColumnSchemaType( + dataType: r.colByName('DATA_TYPE') ?? '', + columnType: r.colByName('COLUMN_TYPE') ?? '', + ); + final isNullable = + (r.colByName('IS_NULLABLE') ?? 'YES').toUpperCase() == 'YES'; final isPk = primaryKeys.contains(name); final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; final dflt = r.colByName('COLUMN_DEFAULT'); + final extra = (r.colByName('EXTRA') ?? '').toLowerCase(); + final omitOnInsert = extra.contains('auto_increment') || + extra.contains('virtual generated') || + extra.contains('stored generated'); + final hasServerDefault = + (dflt != null && dflt.isNotEmpty) || extra.contains('auto_increment'); columns.add( TableColumnMeta( @@ -422,6 +562,8 @@ class MysqlConnection { isPrimaryKey: isPk, primaryKeyPosition: pkPos, defaultValue: dflt, + omitOnInsert: omitOnInsert, + hasServerDefault: hasServerDefault, ), ); } @@ -434,6 +576,28 @@ class MysqlConnection { ); } + /// InnoDB `TABLE_ROWS` estimate (not a blocking `COUNT(*)`). + Future estimateTableRows({ + required String database, + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final rs = await execute( + 'SELECT TABLE_ROWS FROM information_schema.TABLES ' + 'WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table', + { + 'database': database, + 'table': table, + }, + ); + if (rs.rows.isEmpty) return null; + final v = rs.rows.first.colAt(0); + if (v == null || v.isEmpty) return null; + return int.tryParse(v); + } + /// Returns primary key column names for [table] in [database]. Future> getPrimaryKeys({ required String database, diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart index 1f7a4216..74903ff1 100644 --- a/lib/core/database/mysql_connection_pool.dart +++ b/lib/core/database/mysql_connection_pool.dart @@ -6,8 +6,18 @@ import 'package:querya_desktop/core/storage/local_db.dart'; /// Session policy for pooled connections: browse vs SQL editor (writes). enum MysqlSessionMode { + /// Tree catalog, stats, and Table Browser SELECT/COUNT. readOnly, + + /// SQL editor. Must not be shared with Table Browser. readWrite, + + /// Table Browser Save (own TCP session so START TRANSACTION / SET / USE do not leak). + tableWrite, +} + +extension MysqlSessionModeReadOnly on MysqlSessionMode { + bool get isReadOnlySession => this == MysqlSessionMode.readOnly; } typedef MysqlPoolConnectionFactory = Future Function( @@ -68,9 +78,7 @@ class MysqlConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection.setSessionReadOnly( - mode == MysqlSessionMode.readOnly, - ); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return MysqlLease._(this, k, entry.connection); } @@ -105,9 +113,7 @@ class MysqlConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection.setSessionReadOnly( - mode == MysqlSessionMode.readOnly, - ); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return MysqlLease._(this, k, entry.connection); } @@ -156,6 +162,26 @@ class MysqlConnectionPool { _removeEntryClosing(k); } + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) { + for (final mode in MysqlSessionMode.values) { + interrupt(row, database: database, mode: mode); + } + } + + /// Whether the SQL-editor slot currently has an open transaction. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) async { + final entry = _pool[keyFor(row.id, database, MysqlSessionMode.readWrite)]; + if (entry == null || !entry.connection.isConnected) return false; + return await entry.connection.inOpenTransaction() ?? false; + } + Future disconnectAll() async { for (final entry in _pool.values) { entry.idleTimer?.cancel(); diff --git a/lib/core/database/mysql_result_cells.dart b/lib/core/database/mysql_result_cells.dart new file mode 100644 index 00000000..76733e68 --- /dev/null +++ b/lib/core/database/mysql_result_cells.dart @@ -0,0 +1,106 @@ +import 'dart:typed_data'; + +import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +/// Prefer `COLUMN_TYPE` (`tinyint(1)`, `bit(8)`) over `DATA_TYPE` (`tinyint`). +String mysqlColumnSchemaType({ + required String dataType, + required String columnType, +}) { + final ct = columnType.trim(); + if (ct.isNotEmpty) return ct; + return dataType; +} + +/// Grid/SQL display for a MySQL cell: BOOLEAN as true/false, BIT/BLOB as `0x` hex. +String mysqlResultCellToDisplayString( + Object? value, { + ResultSetColumn? column, + String? schemaDataType, + StringInternPool? pool, +}) { + if (value == null) { + return pool?.intern('NULL') ?? 'NULL'; + } + final typeName = schemaDataType ?? ''; + final binary = (column?.isBinaryPayload ?? false) || + (typeName.isNotEmpty && TableMutationEngine.isBinaryType(typeName)); + if (binary) { + final hex = binaryCellToHexDisplay(value); + return pool?.intern(hex) ?? hex; + } + final boolean = (column?.isBooleanTiny ?? false) || + (typeName.isNotEmpty && TableMutationEngine.isBoolType(typeName)); + if (boolean) { + final shown = _boolDisplay(value.toString()); + return pool?.intern(shown) ?? shown; + } + return resultCellToDisplayString(value, pool); +} + +/// BIT/BLOB/BINARY cell → `0xdeadbeef` (or `NULL`). +String binaryCellToHexDisplay(Object value) { + if (value is Uint8List) { + return _hexPrefix(value); + } + if (value is List) { + return _hexPrefix(Uint8List.fromList(value)); + } + final raw = value.toString(); + final trimmed = raw.trim(); + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } + if (_looksLikeHexLiteral(trimmed)) { + var hex = trimmed; + 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); + } + final clean = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), '').toLowerCase(); + return '0x$clean'; + } + return _hexPrefix( + Uint8List.fromList(raw.codeUnits.map((u) => u & 0xFF).toList()), + ); +} + +bool _looksLikeHexLiteral(String trimmed) { + if (trimmed.startsWith('0x') || + trimmed.startsWith('0X') || + trimmed.startsWith(r'\x') || + trimmed.startsWith(r'\X')) { + return true; + } + if ((trimmed.startsWith("x'") || trimmed.startsWith("X'")) && + trimmed.endsWith("'")) { + return true; + } + return false; +} + +String _hexPrefix(Uint8List bytes) { + final out = StringBuffer('0x'); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + return out.toString(); +} + +String _boolDisplay(String raw) { + final trimmed = raw.trim().toLowerCase(); + if (trimmed == '1' || trimmed == 'true' || trimmed == 't') { + return 'true'; + } + if (trimmed == '0' || trimmed == 'false' || trimmed == 'f') { + return 'false'; + } + return raw; +} diff --git a/lib/core/database/mysql_service.dart b/lib/core/database/mysql_service.dart index 276ccf60..416a160d 100644 --- a/lib/core/database/mysql_service.dart +++ b/lib/core/database/mysql_service.dart @@ -3,7 +3,11 @@ import 'package:querya_desktop/core/database/mysql_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; export 'mysql_connection_pool.dart' - show MysqlLease, MysqlSessionMode, MysqlConnectionPool; + show + MysqlLease, + MysqlSessionMode, + MysqlSessionModeReadOnly, + MysqlConnectionPool; Future _defaultCreateAndConnect( ConnectionRow row, { @@ -15,7 +19,7 @@ Future _defaultCreateAndConnect( database: database.isEmpty ? null : database, ); await conn.connect(); - await conn.setSessionReadOnly(mode == MysqlSessionMode.readOnly); + await conn.setSessionReadOnly(mode.isReadOnlySession); return conn; } @@ -48,5 +52,19 @@ class MysqlService { }) => _pool.interrupt(row, database: database, mode: mode); + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) => + _pool.interruptAllModes(row, database: database); + + /// Whether the SQL-editor read-write slot has an open transaction. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) => + _pool.hasOpenSqlTransaction(row, database: database); + Future disconnectAll() => _pool.disconnectAll(); } diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 87831f55..8507de0d 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -10,6 +10,8 @@ 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 'postgres_sql.dart'; import 'table_schema_meta.dart'; /// Replaces the database in a `postgresql://` / `postgres://` URI (path or @@ -37,6 +39,30 @@ String replaceDatabaseInConnectionString( return uri.replace(queryParameters: params).toString(); } +/// Host/port TLS mode when the URI has no `sslmode`. +/// +/// [uriSslMode] wins (URI is source of truth). Otherwise encrypt-only +/// (`require`) unless a Root CA is set, then `verifyFull`. `require` does not +/// check the CA — MITM is possible. +SslMode postgresResolveSslMode({ + SslMode? uriSslMode, + required bool encrypt, + required bool hasRootCert, +}) { + if (uriSslMode != null) return uriSslMode; + if (!encrypt && !hasRootCert) return SslMode.disable; + if (hasRootCert) return SslMode.verifyFull; + return SslMode.require; +} + +/// `pg_class.reltuples` → row estimate. Negative / unanalyzed → `null`. +int? postgresReltuplesEstimate(Object? value) { + if (value == null) return null; + final n = value is num ? value.toDouble() : double.tryParse(value.toString()); + if (n == null || n < 0) return null; + return n.round(); +} + /// PostgreSQL connection using the pure-Dart `postgres` package. class PostgresConnection { PostgresConnection({ @@ -106,6 +132,7 @@ class PostgresConnection { Connection? _conn; bool _isConnected = false; + bool _inTransaction = false; bool get isConnected => _isConnected && _conn != null; @@ -145,9 +172,10 @@ class PostgresConnection { } } return ConnectionSettings( - sslMode: (useSSL || securityContext != null) - ? SslMode.require - : SslMode.disable, + sslMode: postgresResolveSslMode( + encrypt: useSSL || securityContext != null, + hasRootCert: sslRootCert != null && sslRootCert!.trim().isNotEmpty, + ), connectTimeout: const Duration(seconds: 10), queryTimeout: const Duration(seconds: 30), securityContext: securityContext, @@ -184,8 +212,14 @@ class PostgresConnection { dbName, ); final parsed = parseConnectionString(uriForOpen); - final sslMode = - parsed.sslMode ?? (useSSL ? SslMode.require : SslMode.disable); + final sslMode = postgresResolveSslMode( + uriSslMode: parsed.sslMode, + encrypt: useSSL || + (sslRootCert != null && sslRootCert!.trim().isNotEmpty) || + (sslCert != null && sslCert!.trim().isNotEmpty) || + (sslKey != null && sslKey!.trim().isNotEmpty), + hasRootCert: sslRootCert != null && sslRootCert!.trim().isNotEmpty, + ); _conn = await Connection.open( parsed.endpoints.first, settings: ConnectionSettings( @@ -206,6 +240,7 @@ class PostgresConnection { ); } _isConnected = true; + _inTransaction = false; scrubCredentials(); } catch (e, st) { _isConnected = false; @@ -223,6 +258,7 @@ class PostgresConnection { Future disconnect() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; try { @@ -236,6 +272,7 @@ class PostgresConnection { /// cancelling a long query or [PostgresService.interrupt]. Future forceClose() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; try { @@ -281,7 +318,9 @@ class PostgresConnection { throw StateError('Not connected to PostgreSQL'); } try { - return await _conn!.execute(sql, timeout: timeout); + final result = await _conn!.execute(sql, timeout: timeout); + _inTransaction = applyPostgresTransactionSql(_inTransaction, sql); + return result; } on TimeoutException { unawaited(forceClose()); rethrow; @@ -303,19 +342,24 @@ class PostgresConnection { } } - /// Whether the session has an open transaction (PostgreSQL 13+). - /// Returns `null` if the server does not support the probe or an error occurs. + /// Whether the session has an open transaction. + /// + /// `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` + /// stays NULL. We track BEGIN/COMMIT/ROLLBACK on [execute], and otherwise + /// probe `pg_stat_activity.xact_start` for this backend (PG 9+; no PG 13 + /// requirement). Returns `null` only when disconnected. Future inOpenTransaction() async { if (!isConnected || _conn == null) return null; + if (_inTransaction) return true; try { - final r = await _conn!.execute( - 'SELECT pg_current_xact_id_if_assigned() IS NOT NULL', - ); - if (r.isEmpty) return null; - return r.first[0] as bool; + final r = await _conn!.execute(kPostgresOpenTransactionProbeSql); + if (r.isEmpty) return false; + final open = r.first[0] == true; + _inTransaction = open; + return open; } catch (e) { debugPrint('PostgresConnection.inOpenTransaction: $e'); - return null; + return _inTransaction; } } @@ -398,7 +442,8 @@ 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 ' 'ORDER BY ordinal_position', @@ -426,11 +471,22 @@ 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[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) || + (isIdentity && identityGeneration == 'BY DEFAULT'); columns.add( TableColumnMeta( @@ -440,6 +496,8 @@ class PostgresConnection { isPrimaryKey: isPk, primaryKeyPosition: pkPos, defaultValue: dflt, + omitOnInsert: omitOnInsert, + hasServerDefault: hasServerDefault, ), ); } @@ -452,6 +510,29 @@ class PostgresConnection { ); } + /// Planner row estimate (`pg_class.reltuples`), not a blocking `COUNT(*)`. + /// Unanalyzed relations (`-1`) and missing catalog rows return `null`. + Future estimateTableRows({ + String schema = 'public', + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + 'SELECT c.reltuples FROM pg_catalog.pg_class c ' + 'JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace ' + 'WHERE n.nspname = @schema AND c.relname = @table ' + "AND c.relkind IN ('r', 'p', 'm', 'f') " + 'LIMIT 1', + ), + parameters: {'schema': schema, 'table': table}, + ); + if (result.isEmpty) return null; + return postgresReltuplesEstimate(result.first[0]); + } + /// Returns primary key column names for [table] in [schema]. Future> getPrimaryKeys({ String schema = 'public', diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 8ed85b32..575fbf53 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -7,10 +7,19 @@ import 'package:querya_desktop/core/storage/local_db.dart'; /// Session policy for pooled connections: browse-only vs ad-hoc SQL (writes). enum PgSessionMode { /// `SET default_transaction_read_only = ON` after connect. + /// Tree catalog, stats, and Table Browser SELECT. readOnly, - /// Read-write session (SQL editor, probes that need catalog writes — rare). + /// SQL editor read-write session. Must not be shared with Table Browser. readWrite, + + /// Table Browser Save / `REFRESH MATERIALIZED VIEW` (own TCP session). + tableWrite, +} + +extension PgSessionModeReadOnly on PgSessionMode { + /// Whether this slot should `SET default_transaction_read_only = ON`. + bool get isReadOnlySession => this == PgSessionMode.readOnly; } /// Creates a connected [PostgresConnection] for the pool (real or fake in tests). @@ -81,8 +90,7 @@ class PostgresConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection - .setSessionReadOnly(mode == PgSessionMode.readOnly); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return PgLease._(this, k, entry.connection); } @@ -90,7 +98,8 @@ class PostgresConnectionPool { try { await _creationLock.createIfAbsent(k, () async { _evictIfNeededBeforeNewSlot(); - final conn = await createAndConnect(row, database: database, mode: mode); + final conn = + await createAndConnect(row, database: database, mode: mode); _pool[k] = _PoolEntry(conn); return conn; }); @@ -116,8 +125,7 @@ class PostgresConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection - .setSessionReadOnly(mode == PgSessionMode.readOnly); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return PgLease._(this, k, entry.connection); } @@ -169,6 +177,29 @@ class PostgresConnectionPool { _removeEntryClosing(k); } + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) { + for (final mode in PgSessionMode.values) { + interrupt(row, database: database, mode: mode); + } + } + + /// Whether the SQL-editor slot currently has an open transaction. + /// + /// Does not acquire a new connection; returns false if the slot is idle or + /// disconnected. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) async { + final entry = _pool[keyFor(row.id, database, PgSessionMode.readWrite)]; + if (entry == null || !entry.connection.isConnected) return false; + return await entry.connection.inOpenTransaction() ?? false; + } + /// Closes all pooled connections (e.g. app shutdown). Future disconnectAll() async { for (final entry in _pool.values) { diff --git a/lib/core/database/postgres_result_cells.dart b/lib/core/database/postgres_result_cells.dart new file mode 100644 index 00000000..307daa47 --- /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/postgres_service.dart b/lib/core/database/postgres_service.dart index 940e1338..e695f933 100644 --- a/lib/core/database/postgres_service.dart +++ b/lib/core/database/postgres_service.dart @@ -3,7 +3,7 @@ import 'package:querya_desktop/core/database/postgres_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; export 'postgres_connection_pool.dart' - show PgLease, PgSessionMode, PostgresConnectionPool; + show PgLease, PgSessionMode, PgSessionModeReadOnly, PostgresConnectionPool; Future _defaultCreateAndConnect( ConnectionRow row, { @@ -12,7 +12,7 @@ Future _defaultCreateAndConnect( }) async { final conn = PostgresConnection.fromConnectionRow(row, database: database); await conn.connect(); - await conn.setSessionReadOnly(mode == PgSessionMode.readOnly); + await conn.setSessionReadOnly(mode.isReadOnlySession); return conn; } @@ -51,6 +51,20 @@ class PostgresService { }) => _pool.interrupt(row, database: database, mode: mode); + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) => + _pool.interruptAllModes(row, database: database); + + /// Whether the SQL-editor read-write slot has an open transaction. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) => + _pool.hasOpenSqlTransaction(row, database: database); + /// Closes all pooled connections (e.g. app shutdown). Future disconnectAll() => _pool.disconnectAll(); } diff --git a/lib/core/database/postgres_sql.dart b/lib/core/database/postgres_sql.dart index 0eff57f6..fd746b07 100644 --- a/lib/core/database/postgres_sql.dart +++ b/lib/core/database/postgres_sql.dart @@ -26,3 +26,66 @@ bool shouldSkipImplicitBegin(String sql) { return false; } + +/// Own-backend `xact_start` (set at `BEGIN`, even when no XID is assigned). +/// +/// Replaces `pg_current_xact_id_if_assigned()` (PG 13+), which stays NULL after +/// `BEGIN` + `SELECT`. `pg_stat_activity.xact_start` exists on PG 9+. +const kPostgresOpenTransactionProbeSql = + 'SELECT xact_start IS NOT NULL ' + 'FROM pg_catalog.pg_stat_activity ' + 'WHERE pid = pg_backend_pid()'; + +/// Updates session tx state from a successfully executed statement. +/// +/// `BEGIN` / `START TRANSACTION` open; `COMMIT` / `END` / `ROLLBACK` close. +/// `ROLLBACK TO` (savepoint) leaves the transaction open. +bool applyPostgresTransactionSql(bool currentlyOpen, String sql) { + final s = stripLeadingWhitespaceAndLineComments(sql); + if (s.isEmpty) return currentlyOpen; + final u = s.toUpperCase(); + + if (u.startsWith('START TRANSACTION') || _pgTxStartsWithKeyword(u, 'BEGIN')) { + return true; + } + if (_pgTxStartsWithKeyword(u, 'COMMIT') || _pgTxStartsWithKeyword(u, 'END')) { + return false; + } + if (_pgTxStartsWithKeyword(u, 'ROLLBACK')) { + if (RegExp(r'^ROLLBACK\s+TO\b').hasMatch(u)) return currentlyOpen; + return false; + } + return currentlyOpen; +} + +bool _pgTxStartsWithKeyword(String upper, String kw) { + if (!upper.startsWith(kw)) return false; + if (upper.length == kw.length) return true; + final next = upper.codeUnitAt(kw.length); + final isIdent = (next >= 0x41 && next <= 0x5a) || + next == 0x5f || + (next >= 0x30 && next <= 0x39); + return !isIdent; +} + +/// Runs [statements] as separate extended-protocol executes inside BEGIN/COMMIT. +/// +/// PostgreSQL Parse cannot contain multiple commands, so +/// `BEGIN; UPDATE …; COMMIT;` in one [execute] fails. +Future runPostgresStatementsInTransaction( + Future Function(String sql) execute, + Iterable statements, +) async { + await execute('BEGIN'); + try { + for (final sql in statements) { + await execute(sql); + } + await execute('COMMIT'); + } catch (_) { + try { + await execute('ROLLBACK'); + } catch (_) {} + rethrow; + } +} diff --git a/lib/core/database/redis_bulk.dart b/lib/core/database/redis_bulk.dart new file mode 100644 index 00000000..ec602d0c --- /dev/null +++ b/lib/core/database/redis_bulk.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:redis/redis.dart' as redis; + +/// Bulk string from Redis: UTF-8 text when valid, otherwise raw bytes. +class RedisBulkValue { + RedisBulkValue._({required this.bytes, required this.text}); + + factory RedisBulkValue.utf8(String value) { + return RedisBulkValue._( + bytes: Uint8List.fromList(utf8.encode(value)), + text: value, + ); + } + + factory RedisBulkValue.fromReply(Object? reply) { + if (reply == null) { + return RedisBulkValue._(bytes: Uint8List(0), text: ''); + } + if (reply is String) { + return RedisBulkValue.utf8(reply); + } + if (reply is int) { + return RedisBulkValue.utf8(reply.toString()); + } + if (reply is double) { + return RedisBulkValue.utf8(reply.toString()); + } + final bytes = _asBytes(reply); + if (bytes != null) { + return RedisBulkValue._(bytes: bytes, text: _tryUtf8(bytes)); + } + return RedisBulkValue.utf8(reply.toString()); + } + + final Uint8List bytes; + + /// Null when [bytes] are not valid UTF-8. + final String? text; + + bool get isUtf8 => text != null; + + bool get isEmpty => bytes.isEmpty; + + /// Argument for `send_object`: a Dart [String] when UTF-8, else [redis.RedisBulk]. + Object get commandArg => isUtf8 ? text! : redis.RedisBulk(bytes); + + /// UI label: the UTF-8 string, or a hex preview that is not `List.toString()`. + String get label { + if (text != null) return text!; + if (bytes.isEmpty) return '(empty)'; + final hex = toHex(); + if (hex.length <= 48) return '0x$hex'; + return '0x${hex.substring(0, 48)}… (${bytes.length} B)'; + } + + String toHex() { + final out = StringBuffer(); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + return out.toString(); + } + + String toBase64() => base64Encode(bytes); + + @override + bool operator ==(Object other) { + if (other is! RedisBulkValue || other.bytes.length != bytes.length) { + return false; + } + for (var i = 0; i < bytes.length; i++) { + if (other.bytes[i] != bytes[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll(bytes); + + @override + String toString() => label; +} + +int redisReplyInt(Object? reply, [int fallback = 0]) { + if (reply is int) return reply; + final text = RedisBulkValue.fromReply(reply).text; + return int.tryParse(text ?? '') ?? fallback; +} + +double redisReplyDouble(Object? reply, [double fallback = 0]) { + if (reply is num) return reply.toDouble(); + final text = RedisBulkValue.fromReply(reply).text; + return double.tryParse(text ?? '') ?? fallback; +} + +Object redisCommandArg(Object value) { + if (value is RedisBulkValue) return value.commandArg; + return value; +} + +Uint8List? _asBytes(Object reply) { + if (reply is Uint8List) return reply; + if (reply is List) return Uint8List.fromList(reply); + if (reply is List && reply.isNotEmpty && reply.every((e) => e is int)) { + return Uint8List.fromList(List.from(reply)); + } + if (reply is List && reply.isEmpty) return Uint8List(0); + return null; +} + +String? _tryUtf8(Uint8List bytes) { + try { + return utf8.decode(bytes); + } on FormatException { + return null; + } +} diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 98ea8ce3..3684e119 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -3,9 +3,16 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:redis/redis.dart' as redis; +/// First page / load-more size for hash, list, set, and zset editors. +const redisCollectionPageSize = 200; + +/// Warn in the key editor when HLEN / LLEN / SCARD / ZCARD is at least this. +const redisLargeCollectionWarnAt = 10000; + /// Redis connection using the Dart redis package (no Java/JRE). class RedisConnection { RedisConnection({ @@ -20,7 +27,14 @@ class RedisConnection { }) : _password = password, _connectionString = connectionString; - factory RedisConnection.fromConnectionRow(ConnectionRow row) { + /// Parses [ConnectionRow.connectionString] (`redis://` / `rediss://`) for + /// host, port, userinfo, and TLS. Pass [id] `-1` for a sidebar probe that + /// must not share the workspace pool id (and skips secrets lookup). + factory RedisConnection.fromConnectionRow( + ConnectionRow row, { + int? id, + }) { + final resolvedId = id ?? row.id ?? 0; final uriText = row.connectionString?.trim(); if (uriText != null && uriText.isNotEmpty) { final parsed = Uri.parse(uriText); @@ -37,7 +51,7 @@ class RedisConnection { } } return RedisConnection( - id: row.id ?? 0, + id: resolvedId, name: row.name, host: parsed.host.isEmpty ? (row.host ?? 'localhost') : parsed.host, port: parsed.hasPort ? parsed.port : (row.port ?? 6379), @@ -48,7 +62,7 @@ class RedisConnection { ); } return RedisConnection( - id: row.id ?? 0, + id: resolvedId, name: row.name, host: row.host ?? 'localhost', port: row.port ?? 6379, @@ -90,7 +104,8 @@ class RedisConnection { var effectiveConnectionString = _connectionString; if ((effectivePassword == null || effectivePassword.isEmpty) && - (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + (effectiveConnectionString == null || + effectiveConnectionString.isEmpty) && id > 0) { try { final secrets = await ConnectionSecretsStore.readForConnection(id); @@ -114,9 +129,11 @@ class RedisConnection { } else { _command = await _conn!.connect(host, port); } + _command!.setParser(redis.RedisParserBulkBinary()); if (effectivePassword != null && effectivePassword.isNotEmpty) { if (username != null && username!.trim().isNotEmpty) { - await _command!.send_object(['AUTH', username!.trim(), effectivePassword]); + await _command! + .send_object(['AUTH', username!.trim(), effectivePassword]); } else { await _command!.send_object(['AUTH', effectivePassword]); } @@ -131,6 +148,13 @@ class RedisConnection { throw RedisConnectionException('PING failed'); } _isConnected = true; + if (_clientReadOnly) { + try { + await _command!.send_object(['READONLY']); + } catch (_) { + // Standalone / older servers: READONLY is cluster-replica only. + } + } scrubCredentials(); } @@ -158,7 +182,7 @@ class RedisConnection { throw StateError('Not connected to Redis'); } final result = await _command!.send_object(['INFO']); - return result?.toString() ?? ''; + return RedisBulkValue.fromReply(result).text ?? ''; } Future testConnection() async { @@ -175,6 +199,28 @@ class RedisConnection { // ─── Data commands ───────────────────────────────────────────────────── + bool _clientReadOnly = false; + + /// Title-bar / session lock. Write helpers throw; [connect] may send + /// `READONLY` (Redis Cluster replica; ignored or missing on standalone). + bool get clientReadOnly => _clientReadOnly; + + /// Sets the local write lock and, when already connected, sends + /// `READONLY` / `READWRITE`. Errors from older servers are ignored. + Future applyClientReadOnly(bool readOnly) async { + _clientReadOnly = readOnly; + if (!isConnected) return; + try { + await sendCommand([readOnly ? 'READONLY' : 'READWRITE']); + } catch (_) {} + } + + void _assertWritable() { + if (_clientReadOnly) { + throw StateError('Redis connection is read-only'); + } + } + /// Raw command helper. Future sendCommand(List args) async { if (!isConnected || _command == null) { @@ -191,7 +237,7 @@ class RedisConnection { /// DBSIZE — number of keys in the currently selected database. Future dbSize() async { final result = await sendCommand(['DBSIZE']); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + return redisReplyInt(result); } /// CONFIG GET databases — max number of databases. @@ -199,7 +245,7 @@ class RedisConnection { try { final result = await sendCommand(['CONFIG', 'GET', 'databases']); if (result is List && result.length >= 2) { - return int.tryParse(result[1].toString()) ?? 16; + return redisReplyInt(result[1], 16); } } catch (e) { debugPrint('RedisConnection.getMaxDatabases: $e'); @@ -210,7 +256,7 @@ class RedisConnection { /// SCAN cursor [MATCH pattern] [COUNT count]. /// Returns (nextCursor, keys). - Future<(int, List)> scan({ + Future<(int, List)> scan({ int cursor = 0, String? match, int count = 100, @@ -222,24 +268,24 @@ class RedisConnection { args.addAll(['COUNT', count]); final result = await sendCommand(args); if (result is List && result.length == 2) { - final nextCursor = int.tryParse(result[0].toString()) ?? 0; + final nextCursor = redisReplyInt(result[0]); final keys = - (result[1] as List?)?.map((e) => e.toString()).toList() ?? []; + (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; return (nextCursor, keys); } - return (0, []); + return (0, []); } /// TYPE key. - Future keyType(String key) async { - final result = await sendCommand(['TYPE', key]); - return result?.toString() ?? 'none'; + Future keyType(Object key) async { + final result = await sendCommand(['TYPE', redisCommandArg(key)]); + return RedisBulkValue.fromReply(result).text ?? 'none'; } /// TTL key (returns -1 if no expiry, -2 if missing). - Future ttl(String key) async { - final result = await sendCommand(['TTL', key]); - return result is int ? result : int.tryParse(result.toString()) ?? -1; + Future ttl(Object key) async { + final result = await sendCommand(['TTL', redisCommandArg(key)]); + return redisReplyInt(result, -1); } /// Pipelined TYPE + TTL for a SCAN batch. @@ -247,7 +293,8 @@ class RedisConnection { /// Writes all commands before awaiting replies (redis-dart FIFO parse /// queue + optional Nagle via [Command.pipe_start]), so a batch of N keys /// costs ~1 RTT instead of ~2N sequential round-trips. - Future> typesAndTtls(List keys) async { + Future> typesAndTtls( + List keys) async { if (keys.isEmpty) return const []; if (!isConnected) { throw StateError('Not connected to Redis'); @@ -258,170 +305,269 @@ class RedisConnection { try { final typeFutures = >[ for (final key in keys) - sendCommand(['TYPE', key]).then( - (v) => v?.toString() ?? 'none', + sendCommand(['TYPE', key.commandArg]).then( + (v) => RedisBulkValue.fromReply(v).text ?? 'none', onError: (_) => 'unknown', ), ]; final ttlFutures = >[ for (final key in keys) - sendCommand(['TTL', key]).then( - (v) => v is int ? v : int.tryParse(v.toString()) ?? -1, + sendCommand(['TTL', key.commandArg]).then( + (v) => redisReplyInt(v, -1), onError: (_) => -1, ), ]; final types = await Future.wait(typeFutures); final ttls = await Future.wait(ttlFutures); return [ - for (var i = 0; i < keys.length; i++) - (type: types[i], ttl: ttls[i]), + for (var i = 0; i < keys.length; i++) (type: types[i], ttl: ttls[i]), ]; } finally { cmd?.pipe_end(); } } - /// GET (string). - Future get(String key) async { - final result = await sendCommand(['GET', key]); - return result?.toString(); + /// GET. Returns null for a missing key. Non-UTF-8 values keep raw bytes. + Future get(Object key) async { + final result = await sendCommand(['GET', redisCommandArg(key)]); + if (result == null) return null; + return RedisBulkValue.fromReply(result); } /// SET key value [EX seconds]. - Future set(String key, String value, {int? ttlSeconds}) async { + /// + /// When [ttlSeconds] is omitted and [keepTtl] is true (the default), the + /// existing expiry is kept (`KEEPTTL`, Redis 6+). Falls back to `TTL` then + /// `SET … EX` on older servers. Does not recreate a key that is already gone + /// (TTL `-2` / `XX` miss). + Future set( + Object key, + String value, { + int? ttlSeconds, + bool keepTtl = true, + }) async { + _assertWritable(); + final k = redisCommandArg(key); if (ttlSeconds != null && ttlSeconds > 0) { - await sendCommand(['SET', key, value, 'EX', ttlSeconds]); + await sendCommand(['SET', k, value, 'EX', ttlSeconds]); + return; + } + if (!keepTtl) { + await sendCommand(['SET', k, value]); + return; + } + await _setPreservingTtl(k, value); + } + + Future _setPreservingTtl(Object key, String value) async { + try { + final result = await sendCommand(['SET', key, value, 'KEEPTTL', 'XX']); + if (_isRedisNil(result)) { + throw StateError('Key no longer exists'); + } + return; + } catch (e) { + if (e is StateError) rethrow; + if (!_isKeepTtlUnsupported(e)) rethrow; + } + + final existingTtl = await ttl(key); + if (existingTtl == -2) { + throw StateError('Key no longer exists'); + } + if (existingTtl > 0) { + await sendCommand(['SET', key, value, 'EX', existingTtl]); } else { await sendCommand(['SET', key, value]); } } - /// HGETALL key. Returns a `Map`. - Future> hgetall(String key) async { - final result = await sendCommand(['HGETALL', key]); - final map = {}; + /// HGETALL key. + Future> hgetall(Object key) async { + final result = await sendCommand(['HGETALL', redisCommandArg(key)]); + final map = {}; if (result is List) { for (var i = 0; i + 1 < result.length; i += 2) { - map[result[i].toString()] = result[i + 1].toString(); + map[RedisBulkValue.fromReply(result[i])] = + RedisBulkValue.fromReply(result[i + 1]); } } return map; } + /// HSCAN key cursor [COUNT count]. Returns (nextCursor, fields). + Future<(int, Map)> hscan( + Object key, { + int cursor = 0, + int count = redisCollectionPageSize, + }) async { + final result = await sendCommand( + ['HSCAN', redisCommandArg(key), cursor, 'COUNT', count]); + if (result is List && result.length == 2) { + final nextCursor = redisReplyInt(result[0]); + final map = {}; + final pairs = result[1]; + if (pairs is List) { + for (var i = 0; i + 1 < pairs.length; i += 2) { + map[RedisBulkValue.fromReply(pairs[i])] = + RedisBulkValue.fromReply(pairs[i + 1]); + } + } + return (nextCursor, map); + } + return (0, {}); + } + + /// HLEN key. + Future hlen(Object key) async { + final result = await sendCommand(['HLEN', redisCommandArg(key)]); + return redisReplyInt(result); + } + /// HSET key field value. - Future hset(String key, String field, String value) async { - await sendCommand(['HSET', key, field, value]); + Future hset(Object key, String field, String value) async { + _assertWritable(); + await sendCommand(['HSET', redisCommandArg(key), field, value]); } /// HDEL key field. - Future hdel(String key, String field) async { - await sendCommand(['HDEL', key, field]); + Future hdel(Object key, Object field) async { + _assertWritable(); + await sendCommand(['HDEL', redisCommandArg(key), redisCommandArg(field)]); } /// LRANGE key start stop. - Future> lrange(String key, int start, int stop) async { - final result = await sendCommand(['LRANGE', key, start, stop]); + Future> lrange(Object key, int start, int stop) async { + final result = + await sendCommand(['LRANGE', redisCommandArg(key), start, stop]); if (result is List) { - return result.map((e) => e.toString()).toList(); + return result.map(RedisBulkValue.fromReply).toList(); } return []; } /// LLEN key. - Future llen(String key) async { - final result = await sendCommand(['LLEN', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future llen(Object key) async { + final result = await sendCommand(['LLEN', redisCommandArg(key)]); + return redisReplyInt(result); } /// RPUSH key value. - Future rpush(String key, String value) async { - await sendCommand(['RPUSH', key, value]); + Future rpush(Object key, String value) async { + _assertWritable(); + await sendCommand(['RPUSH', redisCommandArg(key), value]); } /// SMEMBERS key. - Future> smembers(String key) async { - final result = await sendCommand(['SMEMBERS', key]); + Future> smembers(Object key) async { + final result = await sendCommand(['SMEMBERS', redisCommandArg(key)]); if (result is List) { - return result.map((e) => e.toString()).toList(); + return result.map(RedisBulkValue.fromReply).toList(); } return []; } + /// SSCAN key cursor [COUNT count]. Returns (nextCursor, members). + Future<(int, List)> sscan( + Object key, { + int cursor = 0, + int count = redisCollectionPageSize, + }) async { + final result = await sendCommand( + ['SSCAN', redisCommandArg(key), cursor, 'COUNT', count]); + if (result is List && result.length == 2) { + final nextCursor = redisReplyInt(result[0]); + final members = + (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; + return (nextCursor, members); + } + return (0, []); + } + /// SCARD key. - Future scard(String key) async { - final result = await sendCommand(['SCARD', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future scard(Object key) async { + final result = await sendCommand(['SCARD', redisCommandArg(key)]); + return redisReplyInt(result); } /// SADD key member. - Future sadd(String key, String member) async { - await sendCommand(['SADD', key, member]); + Future sadd(Object key, String member) async { + _assertWritable(); + await sendCommand(['SADD', redisCommandArg(key), member]); } /// SREM key member. - Future srem(String key, String member) async { - await sendCommand(['SREM', key, member]); + Future srem(Object key, Object member) async { + _assertWritable(); + await sendCommand(['SREM', redisCommandArg(key), redisCommandArg(member)]); } /// ZRANGE key start stop WITHSCORES → list of (member, score). - Future> zrangeWithScores( - String key, int start, int stop) async { - final result = - await sendCommand(['ZRANGE', key, start, stop, 'WITHSCORES']); - final list = <(String, double)>[]; + Future> zrangeWithScores( + Object key, int start, int stop) async { + final result = await sendCommand( + ['ZRANGE', redisCommandArg(key), start, stop, 'WITHSCORES']); + final list = <(RedisBulkValue, double)>[]; if (result is List) { for (var i = 0; i + 1 < result.length; i += 2) { - final member = result[i].toString(); - final score = double.tryParse(result[i + 1].toString()) ?? 0; - list.add((member, score)); + list.add(( + RedisBulkValue.fromReply(result[i]), + redisReplyDouble(result[i + 1]), + )); } } return list; } /// ZCARD key. - Future zcard(String key) async { - final result = await sendCommand(['ZCARD', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future zcard(Object key) async { + final result = await sendCommand(['ZCARD', redisCommandArg(key)]); + return redisReplyInt(result); } /// ZADD key score member. - Future zadd(String key, double score, String member) async { - await sendCommand(['ZADD', key, score, member]); + Future zadd(Object key, double score, String member) async { + _assertWritable(); + await sendCommand(['ZADD', redisCommandArg(key), score, member]); } /// ZREM key member. - Future zrem(String key, String member) async { - await sendCommand(['ZREM', key, member]); + Future zrem(Object key, Object member) async { + _assertWritable(); + await sendCommand(['ZREM', redisCommandArg(key), redisCommandArg(member)]); } /// DEL key. - Future del(String key) async { - final result = await sendCommand(['DEL', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future del(Object key) async { + _assertWritable(); + final result = await sendCommand(['DEL', redisCommandArg(key)]); + return redisReplyInt(result); } /// RENAME old new. Future rename(String oldKey, String newKey) async { + _assertWritable(); await sendCommand(['RENAME', oldKey, newKey]); } /// EXPIRE key seconds. - Future expire(String key, int seconds) async { - await sendCommand(['EXPIRE', key, seconds]); + Future expire(Object key, int seconds) async { + _assertWritable(); + await sendCommand(['EXPIRE', redisCommandArg(key), seconds]); } /// PERSIST key (remove TTL). - Future persist(String key) async { - await sendCommand(['PERSIST', key]); + Future persist(Object key) async { + _assertWritable(); + await sendCommand(['PERSIST', redisCommandArg(key)]); } /// STRLEN / LLEN / SCARD / ZCARD / HLEN — get size for any type. - Future keySize(String key, String type) async { + Future keySize(Object key, String type) async { switch (type) { case 'string': - final r = await sendCommand(['STRLEN', key]); - return r is int ? r : int.tryParse(r.toString()) ?? 0; + final r = await sendCommand(['STRLEN', redisCommandArg(key)]); + return redisReplyInt(r); case 'list': return llen(key); case 'set': @@ -429,8 +575,7 @@ class RedisConnection { case 'zset': return zcard(key); case 'hash': - final r = await sendCommand(['HLEN', key]); - return r is int ? r : int.tryParse(r.toString()) ?? 0; + return hlen(key); default: return 0; } @@ -444,6 +589,21 @@ class RedisConnectionTestFake extends RedisConnection { this.firstScanKeys = const ['alpha', 'beta'], this.secondScanKeys = const [], this.dbSizeResult = 2, + this.getResult, + this.listItems = const [], + this.llenResult, + this.hashFirstPage = const {}, + this.hashSecondPage = const {}, + this.hlenResult, + this.setFirstPage = const [], + this.setSecondPage = const [], + this.scardResult, + this.zsetItems = const <(String, double)>[], + this.zcardResult, + this.typeResult = 'string', + this.failType = false, + this.getBytesResult, + this.binaryScanKeys = const >[], }) : super( id: -1, name: 'test-fake', @@ -454,8 +614,26 @@ class RedisConnectionTestFake extends RedisConnection { final List firstScanKeys; final List secondScanKeys; final int dbSizeResult; + final String? getResult; + final List listItems; + final int? llenResult; + final Map hashFirstPage; + final Map hashSecondPage; + final int? hlenResult; + final List setFirstPage; + final List setSecondPage; + final int? scardResult; + final List<(String, double)> zsetItems; + final int? zcardResult; + final String typeResult; + final bool failType; + final List? getBytesResult; + final List> binaryScanKeys; + final List sentCommands = []; bool _firstScanDone = false; + bool _firstHscanDone = false; + bool _firstSscanDone = false; @override bool get isConnected => _isConnected; @@ -486,6 +664,7 @@ class RedisConnectionTestFake extends RedisConnection { throw StateError('Not connected to Redis'); } final op = args.first.toString().toUpperCase(); + sentCommands.add(op); switch (op) { case 'SELECT': return 'OK'; @@ -493,23 +672,139 @@ class RedisConnectionTestFake extends RedisConnection { return dbSizeResult; case 'SCAN': final cursor = int.tryParse(args[1].toString()) ?? 0; + List keysFor(List named) => + [...named, ...binaryScanKeys]; if (cursor == 0 && !_firstScanDone) { _firstScanDone = true; final next = secondScanKeys.isNotEmpty ? 1 : 0; - return [next, firstScanKeys]; + return [next, keysFor(firstScanKeys)]; } if (cursor == 1 && secondScanKeys.isNotEmpty) { - return [0, secondScanKeys]; + return [0, keysFor(secondScanKeys)]; } - return [0, []]; + return [0, []]; case 'TYPE': - return 'string'; + if (failType) { + throw StateError('TYPE failed'); + } + return typeResult; case 'TTL': return -1; + case 'GET': + if (getBytesResult != null) return getBytesResult; + if (typeResult != 'string') { + throw StateError( + 'WRONGTYPE Operation against a key holding the wrong kind of value', + ); + } + return getResult; + case 'SET': + if (typeResult != 'string') { + throw StateError('SET refused: key is $typeResult'); + } + return 'OK'; + case 'HGETALL': + case 'SMEMBERS': + throw StateError('$op is unbounded; use a paged command'); + case 'HLEN': + return hlenResult ?? hashFirstPage.length + hashSecondPage.length; + case 'LLEN': + return llenResult ?? listItems.length; + case 'SCARD': + return scardResult ?? setFirstPage.length + setSecondPage.length; + case 'ZCARD': + return zcardResult ?? zsetItems.length; + case 'LRANGE': + return _sliceList(listItems, args); + case 'ZRANGE': + final stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded ZRANGE'); + } + final start = int.tryParse(args[2].toString()) ?? 0; + if (start >= zsetItems.length || start > stop) return []; + final end = (stop + 1).clamp(0, zsetItems.length); + final out = []; + for (final (member, score) in zsetItems.sublist(start, end)) { + out.add(member); + out.add(score); + } + return out; + case 'HSCAN': + return _pagedPairs( + cursor: int.tryParse(args[2].toString()) ?? 0, + first: hashFirstPage, + second: hashSecondPage, + firstDone: _firstHscanDone, + markFirst: () => _firstHscanDone = true, + ); + case 'SSCAN': + final cursor = int.tryParse(args[2].toString()) ?? 0; + if (cursor == 0 && !_firstSscanDone) { + _firstSscanDone = true; + final next = setSecondPage.isNotEmpty ? 1 : 0; + return [next, setFirstPage]; + } + if (cursor == 1 && setSecondPage.isNotEmpty) { + return [0, setSecondPage]; + } + return [0, []]; + case 'READONLY': + case 'READWRITE': + return 'OK'; default: return null; } } + + List _sliceList(List items, List args) { + if (items.isEmpty) return const []; + final start = int.tryParse(args[2].toString()) ?? 0; + var stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded LRANGE'); + } + if (start >= items.length || start > stop) return const []; + final end = (stop + 1).clamp(0, items.length); + return items.sublist(start, end); + } + + List _pagedPairs({ + required int cursor, + required Map first, + required Map second, + required bool firstDone, + required void Function() markFirst, + }) { + List flatten(Map map) { + final out = []; + map.forEach((k, v) { + out.add(k); + out.add(v); + }); + return out; + } + + if (cursor == 0 && !firstDone) { + markFirst(); + final next = second.isNotEmpty ? 1 : 0; + return [next, flatten(first)]; + } + if (cursor == 1 && second.isNotEmpty) { + return [0, flatten(second)]; + } + return [0, []]; + } +} + +bool _isRedisNil(Object? result) => + result == null || result.toString().toLowerCase() == 'null'; + +bool _isKeepTtlUnsupported(Object error) { + final s = error.toString().toLowerCase(); + return s.contains('syntax') || + s.contains('keepttl') || + s.contains('wrong number of arguments'); } class RedisConnectionException implements Exception { diff --git a/lib/core/database/redis_service.dart b/lib/core/database/redis_service.dart index 0b7bf2c2..1ad1044d 100644 --- a/lib/core/database/redis_service.dart +++ b/lib/core/database/redis_service.dart @@ -1,48 +1,101 @@ +import 'dart:async'; + import '../storage/local_db.dart'; import 'redis_connection.dart'; +/// Which workspace surface owns a Redis TCP session for a connection id. +/// +/// Overview INFO polling and Explorer SCAN/GET/SELECT cannot share a socket: +/// keep-alive Overview stays mounted under Explorer, and Explorer issues +/// `SELECT` while Overview polls `INFO`. +enum RedisSessionRole { + /// Overview INFO polling (kept alive under Explorer). + stats, + + /// Keys/editor SCAN/GET/SET plus `SELECT`. + explorer, +} + /// Service for Redis connections (Dart redis package, no Java). class RedisService { RedisService._(); static final RedisService instance = RedisService._(); - final Map _connections = {}; + final Map _connections = {}; + + String _key(int id, RedisSessionRole role) => '$id::${role.name}'; + + /// Returns the existing socket for [role], or creates one without touching + /// the other role. + RedisConnection acquire( + ConnectionRow row, { + required RedisSessionRole role, + }) { + if (row.type != 'redis') { + throw ArgumentError('Connection type must be redis'); + } + final id = row.id ?? 0; + final k = _key(id, role); + final existing = _connections[k]; + if (existing != null) return existing; + final conn = RedisConnection.fromConnectionRow(row); + _connections[k] = conn; + return conn; + } - /// Creates (or replaces) a [RedisConnection] for the given [ConnectionRow]. - /// If a connection with the same ID already exists it is disconnected first. - RedisConnection createConnection(ConnectionRow row) { + /// Creates (or replaces) a [RedisConnection] for [role] (default stats). + /// The other role for this id is left alone. + RedisConnection createConnection( + ConnectionRow row, { + RedisSessionRole role = RedisSessionRole.stats, + }) { if (row.type != 'redis') { throw ArgumentError('Connection type must be redis'); } final id = row.id ?? 0; + final k = _key(id, role); - // Disconnect previous connection for this ID, if any. - final existing = _connections[id]; + final existing = _connections.remove(k); if (existing != null) { - existing.disconnect(); // fire-and-forget; disconnect is safe + unawaited(existing.disconnect()); } final conn = RedisConnection.fromConnectionRow(row); - _connections[id] = conn; + _connections[k] = conn; return conn; } - RedisConnection? getConnection(int id) => _connections[id]; + RedisConnection? getConnection( + int id, { + RedisSessionRole role = RedisSessionRole.stats, + }) => + _connections[_key(id, role)]; Future connect(RedisConnection connection) async { await connection.connect(); } + /// Drops [connection] from the pool immediately, then awaits the TCP close. Future disconnect(RedisConnection connection) async { + _connections.removeWhere((_, c) => identical(c, connection)); await connection.disconnect(); - _connections.remove(connection.id); + } + + /// Awaits close of every role for [id] (user Disconnect). + Future disconnectByConnectionId(int id) async { + for (final role in RedisSessionRole.values) { + final conn = _connections.remove(_key(id, role)); + if (conn != null) await conn.disconnect(); + } } /// Disconnects all Redis connections (e.g. app shutdown). Future disconnectAll() async { - for (final connection in _connections.values.toList()) { - await disconnect(connection); + final all = _connections.values.toList(); + _connections.clear(); + for (final connection in all) { + await connection.disconnect(); } } } diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index cbb16c3f..08856086 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -1,4 +1,4 @@ -// Shared helpers for bounding ad-hoc SQL result sets (Postgres, SQLite, …). +// Shared helpers for bounding ad-hoc SQL result sets (Postgres, SQLite, MySQL, …). /// Removes leading whitespace and `--` line comments (not `/* */`). String stripLeadingWhitespaceAndLineComments(String sql) { diff --git a/lib/core/database/sql_table_target_extractor.dart b/lib/core/database/sql_table_target_extractor.dart index 2010fe85..52f0c693 100644 --- a/lib/core/database/sql_table_target_extractor.dart +++ b/lib/core/database/sql_table_target_extractor.dart @@ -21,32 +21,234 @@ class SqlTableTarget { /// Helper utility to infer the primary target table from a simple SELECT query. abstract final class SqlTableTargetExtractor { - static final _fromTableRegex = RegExp( - r'\bfrom\s+(?:(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))\.)?(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))', - caseSensitive: false, - ); + static const _fromFollowers = { + 'where', + 'group', + 'order', + 'limit', + 'offset', + 'having', + 'window', + 'fetch', + 'for', + 'union', + 'except', + 'intersect', + 'returning', + 'on', + 'using', + }; + + static const _joinStarters = { + 'join', + 'inner', + 'left', + 'right', + 'full', + 'cross', + 'natural', + }; /// Extracts the target schema and table name from [sql]. - /// Returns `null` if no simple target table can be determined (e.g. subqueries, joins). + /// + /// Returns `null` if no simple single-table target can be determined + /// (JOIN, comma-FROM, subquery, VALUES, set operations, CTE). static SqlTableTarget? extract(String sql) { final trimmed = sql.trim(); if (trimmed.isEmpty) return null; + if (_startsWithKeyword(trimmed, 'with')) return null; + if (_indexOfTopLevelKeyword(trimmed, 'join') >= 0) return null; + if (_indexOfTopLevelKeyword(trimmed, 'union') >= 0) return null; + if (_indexOfTopLevelKeyword(trimmed, 'except') >= 0) return null; + if (_indexOfTopLevelKeyword(trimmed, 'intersect') >= 0) return null; + + final fromAt = _indexOfTopLevelKeyword(trimmed, 'from'); + if (fromAt < 0) return null; + + var i = fromAt + 4; + i = _skipTrivia(trimmed, i); + if (i >= trimmed.length) return null; + if (trimmed[i] == '(') return null; + + final first = _readIdent(trimmed, i); + if (first == null) return null; + i = first.next; + + String? schema; + String table; + i = _skipTrivia(trimmed, i); + if (i < trimmed.length && trimmed[i] == '.') { + i++; + i = _skipTrivia(trimmed, i); + final second = _readIdent(trimmed, i); + if (second == null) return null; + schema = first.name; + table = second.name; + i = second.next; + } else { + table = first.name; + } + + i = _skipTrivia(trimmed, i); + if (i < trimmed.length) { + final asKw = _readBareWord(trimmed, i); + if (asKw != null && asKw.word.toLowerCase() == 'as') { + i = _skipTrivia(trimmed, asKw.next); + final alias = _readIdent(trimmed, i); + if (alias == null) return null; + i = alias.next; + } else if (asKw != null && + !_fromFollowers.contains(asKw.word.toLowerCase()) && + !_joinStarters.contains(asKw.word.toLowerCase())) { + i = asKw.next; + } + } + + i = _skipTrivia(trimmed, i); + if (i < trimmed.length && trimmed[i] == ',') return null; - // If query has JOIN or multiple tables separated by comma, avoid auto-generating DML - final hasJoin = RegExp(r'\bjoin\b', caseSensitive: false).hasMatch(trimmed); - if (hasJoin) return null; + if (table.isEmpty) return null; + return SqlTableTarget(tableName: table, schema: schema); + } +} - final match = _fromTableRegex.firstMatch(trimmed); - if (match == null) return null; +bool _startsWithKeyword(String sql, String keyword) { + final i = _skipTrivia(sql, 0); + if (i >= sql.length || !_isIdentStart(sql[i])) return false; + final word = _readBareWord(sql, i); + return word != null && word.word.toLowerCase() == keyword.toLowerCase(); +} - final schema = match.group(1) ?? match.group(2) ?? match.group(3); - final table = match.group(4) ?? match.group(5) ?? match.group(6); +/// Whether SQL-grid Save may run for this result set. +/// +/// Requires a simple single-table SELECT, a non-empty primary key, and every +/// PK column present in [resultColumns] (so WHERE can address the row). +bool sqlResultGridSaveEnabled({ + required String? sql, + required List resultColumns, + required List primaryKeys, +}) { + if (sql == null || SqlTableTargetExtractor.extract(sql) == null) { + return false; + } + if (primaryKeys.isEmpty) return false; + final cols = resultColumns.toSet(); + return primaryKeys.every(cols.contains); +} - if (table == null || table.isEmpty) return null; +int _indexOfTopLevelKeyword(String sql, String keyword) { + final want = keyword.toLowerCase(); + var i = 0; + var depth = 0; + while (i < sql.length) { + i = _skipTrivia(sql, i); + if (i >= sql.length) break; + final c = sql[i]; + if (c == "'" || c == '"' || c == '`') { + final end = _skipQuoted(sql, i, c); + if (end < 0) return -1; + i = end; + continue; + } + if (c == '(') { + depth++; + i++; + continue; + } + if (c == ')') { + if (depth > 0) depth--; + i++; + continue; + } + if (depth == 0 && _isIdentStart(c)) { + final start = i; + i++; + while (i < sql.length && _isIdentPart(sql[i])) { + i++; + } + if (sql.substring(start, i).toLowerCase() == want) { + final beforeOk = start == 0 || !_isIdentPart(sql[start - 1]); + final afterOk = i >= sql.length || !_isIdentPart(sql[i]); + if (beforeOk && afterOk) return start; + } + continue; + } + i++; + } + return -1; +} - return SqlTableTarget( - tableName: table, - schema: schema, - ); +int _skipTrivia(String sql, int i) { + while (i < sql.length) { + final c = sql[i]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + i++; + continue; + } + if (c == '-' && i + 1 < sql.length && sql[i + 1] == '-') { + i += 2; + while (i < sql.length && sql[i] != '\n') { + i++; + } + continue; + } + if (c == '/' && i + 1 < sql.length && sql[i + 1] == '*') { + final end = sql.indexOf('*/', i + 2); + i = end < 0 ? sql.length : end + 2; + continue; + } + break; } + return i; +} + +({String name, int next})? _readIdent(String sql, int i) { + if (i >= sql.length) return null; + final c = sql[i]; + if (c == '"' || c == '`' || c == '[') { + final close = c == '[' ? ']' : c; + final end = _skipQuoted(sql, i, close); + if (end < 0) return null; + return (name: sql.substring(i + 1, end - 1), next: end); + } + final word = _readBareWord(sql, i); + if (word == null) return null; + return (name: word.word, next: word.next); +} + +({String word, int next})? _readBareWord(String sql, int i) { + if (i >= sql.length || !_isIdentStart(sql[i])) return null; + final start = i; + i++; + while (i < sql.length && _isIdentPart(sql[i])) { + i++; + } + return (word: sql.substring(start, i), next: i); +} + +int _skipQuoted(String sql, int i, String quote) { + final open = sql[i]; + i++; + while (i < sql.length) { + if (sql[i] == quote) { + if (quote != ']' && i + 1 < sql.length && sql[i + 1] == quote) { + i += 2; + continue; + } + return i + 1; + } + if (open == '[' && sql[i] == ']') return i + 1; + i++; + } + return -1; +} + +bool _isIdentStart(String c) { + final u = c.codeUnitAt(0); + return (u >= 65 && u <= 90) || (u >= 97 && u <= 122) || u == 95; +} + +bool _isIdentPart(String c) { + final u = c.codeUnitAt(0); + return _isIdentStart(c) || (u >= 48 && u <= 57); } diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 153bdf98..e60ee133 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -1,10 +1,52 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.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'; +/// In-memory paths (`:memory:`) skip the missing-file check. +bool sqlitePathIsInMemory(String path) => + path == inMemoryDatabasePath || path == ':memory:'; + +/// Maps driver / OS errors to a user-facing [SqliteConnectionException]. +SqliteConnectionException sqliteMapOpenError(Object error, String path) { + final msg = error.toString().toLowerCase(); + if (msg.contains('not found') || + msg.contains('no such file') || + msg.contains('errno = 2') || + msg.contains('errno =2')) { + return SqliteConnectionException( + 'SQLite file not found: $path', + cause: error, + ); + } + if (msg.contains('permission') || + msg.contains('access denied') || + msg.contains('errno = 13') || + msg.contains('errno =13')) { + return SqliteConnectionException( + 'Permission denied opening SQLite file: $path', + cause: error, + ); + } + if (msg.contains('not a database') || + msg.contains('malformed') || + msg.contains('corrupt') || + msg.contains('disk image')) { + return SqliteConnectionException( + 'SQLite database is corrupt or not a database: $path', + cause: error, + ); + } + return SqliteConnectionException( + 'Failed to open SQLite database: $error', + cause: error, + ); +} + /// SQLite database connection using sqflite_common_ffi. class SqliteConnection { SqliteConnection({ @@ -12,6 +54,7 @@ class SqliteConnection { required this.name, required this.path, this.readOnly = false, + this.createIfMissing = false, }); factory SqliteConnection.fromConnectionRow( @@ -22,7 +65,8 @@ class SqliteConnection { id: row.id ?? 0, name: row.name, path: row.host ?? '', // Store absolute file path in the 'host' field - readOnly: readOnly ?? row.useSSL, // Store read-only flag in the 'useSSL' field + readOnly: + readOnly ?? row.useSSL, // Store read-only flag in the 'useSSL' field ); } @@ -31,8 +75,12 @@ class SqliteConnection { final String path; final bool readOnly; + /// When true, `openDatabase` may create the file (new-connection Save only). + final bool createIfMissing; + Database? _db; bool _isConnected = false; + bool _inTransaction = false; bool get isConnected => _isConnected && _db != null; @@ -40,28 +88,72 @@ class SqliteConnection { if (_isConnected && _db != null) return; try { await LocalDb.initFfi(); + _ensureFileReady(); _db = await databaseFactoryFfi.openDatabase( path, options: OpenDatabaseOptions( readOnly: readOnly, onOpen: (db) async { await db.execute('PRAGMA busy_timeout = 5000'); + await db.rawQuery('SELECT 1'); if (!readOnly) { await db.execute('PRAGMA foreign_keys = ON'); + try { + await db.execute('PRAGMA journal_mode = WAL'); + } catch (e) { + debugPrint('SqliteConnection WAL not available: $e'); + } } }, ), ); _isConnected = true; - } catch (e) { + } on SqliteConnectionException { _isConnected = false; _db = null; rethrow; + } catch (e) { + _isConnected = false; + _db = null; + throw sqliteMapOpenError(e, path); + } + } + + void _ensureFileReady() { + if (sqlitePathIsInMemory(path)) return; + final file = File(path); + if (file.existsSync()) { + if (file.statSync().type == FileSystemEntityType.directory) { + throw SqliteConnectionException('SQLite path is a directory: $path'); + } + return; + } + // sqflite FFI uses OpenMode.readWriteCreate and even mkdir's parents. + if (!createIfMissing || readOnly) { + throw SqliteConnectionException('SQLite file not found: $path'); + } + } + + /// Creates an empty SQLite file if [path] is missing (new-connection Save). + static Future createFileIfMissing(String path) async { + if (sqlitePathIsInMemory(path)) return; + if (File(path).existsSync()) return; + final conn = SqliteConnection( + id: 0, + name: 'create', + path: path, + createIfMissing: true, + ); + try { + await conn.connect(); + } finally { + await conn.disconnect(); } } Future disconnect() async { _isConnected = false; + _inTransaction = false; final d = _db; _db = null; try { @@ -73,17 +165,19 @@ class SqliteConnection { Future forceClose() => disconnect(); - Future testConnection() async { + /// Tests connectivity without leaving a session open. + Future<({bool ok, String? error})> testConnection() async { try { await connect(); if (_db != null) { await _db!.rawQuery('SELECT 1'); - return true; + return (ok: true, error: null); } - return false; + return (ok: false, error: 'Connection could not be established.'); + } on SqliteConnectionException catch (e) { + return (ok: false, error: e.message); } catch (e) { - debugPrint('SqliteConnection.testConnection: $e'); - return false; + return (ok: false, error: sqliteMapOpenError(e, path).message); } finally { await disconnect(); } @@ -97,18 +191,8 @@ class SqliteConnection { if (!isConnected || _db == null) { throw StateError('Not connected to SQLite'); } - final sqlLower = sql - .replaceAll(RegExp(r'--.*$', multiLine: true), '') - .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') - .trim() - .toLowerCase(); - - // SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data - final isReadOnlyQuery = sqlLower.startsWith('select') || - sqlLower.startsWith('pragma') || - sqlLower.startsWith('explain') || - sqlLower.startsWith('with') || - sqlLower.startsWith('values'); + final sqlLower = sqliteStripSqlComments(sql).trim().toLowerCase(); + final isReadOnlyQuery = sqliteSqlIsReadOnlyQuery(sql); final hasReturning = RegExp(r'\breturning\b').hasMatch(sqlLower); @@ -121,14 +205,33 @@ class SqliteConnection { return await _db!.rawQuery(sql, arguments); } else { await _db!.execute(sql, arguments); + _noteTransactionSql(sqlLower); return []; } } on TimeoutException { unawaited(forceClose()); rethrow; + } on DatabaseException catch (e) { + if (_isSqliteBusy(e)) { + throw StateError( + 'SQLite is busy (another connection is writing). Retry in a moment.', + ); + } + rethrow; } } + /// Runs DML and returns sqlite `changes()` for the last INSERT/UPDATE/DELETE. + Future executeAffected(String sql) async { + await execute(sql); + if (!isConnected || _db == null) return 0; + final rows = await _db!.rawQuery('SELECT changes() AS c'); + if (rows.isEmpty) return 0; + final v = rows.first['c']; + if (v is int) return v; + return int.tryParse('$v') ?? 0; + } + /// Runs [execute] with an application-level [timeout]. Future>> executeWithTimeout( String sql, { @@ -145,6 +248,32 @@ class SqliteConnection { } } + /// Whether this session has an open `BEGIN` (tracked from executed SQL). + Future inOpenTransaction() async { + if (!isConnected) return null; + return _inTransaction; + } + + /// Runs [body] inside a transaction. If the session already has a `BEGIN`, + /// DML joins it instead of nesting `BEGIN TRANSACTION`. + Future runInTransaction(Future Function() body) async { + final join = _inTransaction; + if (!join) { + await execute('BEGIN TRANSACTION'); + } + try { + await body(); + if (!join) await execute('COMMIT'); + } catch (e) { + if (!join) { + try { + await execute('ROLLBACK'); + } catch (_) {} + } + rethrow; + } + } + /// Lists tables in the database. Future> listTables() async { final rows = await execute( @@ -166,10 +295,12 @@ class SqliteConnection { final rows = await execute( "SELECT name, tbl_name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name", ); - return rows.map((r) => { - 'name': r['name'] as String, - 'table': r['tbl_name'] as String, - }).toList(); + return rows + .map((r) => { + 'name': r['name'] as String, + 'table': r['tbl_name'] as String, + }) + .toList(); } /// Lists column names for a table. @@ -227,7 +358,7 @@ class SqliteConnection { /// Returns the DDL (`sql`) of a table or view from sqlite_master. Future getObjectDdl(String objectName) async { final rows = await execute( - "SELECT sql FROM sqlite_master WHERE name = :name", + 'SELECT sql FROM sqlite_master WHERE name = ?', [objectName], ); if (rows.isEmpty) return '-- No definition found for $objectName'; @@ -248,8 +379,10 @@ class SqliteConnection { final jmRows = await execute('PRAGMA journal_mode'); final jm = (jmRows.isNotEmpty ? jmRows.first.values.first : '') ?? ''; - final tblCountRows = await execute("SELECT count(*) AS c FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); - final tblCount = (tblCountRows.isNotEmpty ? tblCountRows.first['c'] : 0) ?? 0; + final tblCountRows = await execute( + "SELECT count(*) AS c FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); + final tblCount = + (tblCountRows.isNotEmpty ? tblCountRows.first['c'] : 0) ?? 0; return { 'version': ver, @@ -260,6 +393,30 @@ class SqliteConnection { }; } + void _noteTransactionSql(String sqlLower) { + if (sqlLower.startsWith('begin')) { + _inTransaction = true; + return; + } + if (sqlLower.startsWith('commit') || sqlLower.startsWith('end')) { + _inTransaction = false; + return; + } + if (sqlLower.startsWith('rollback') && + !RegExp(r'^rollback\s+to\b').hasMatch(sqlLower)) { + _inTransaction = false; + } + } + + static bool _isSqliteBusy(DatabaseException e) { + final code = e.getResultCode(); + if (code == 5 || code == 6) return true; + final msg = e.toString().toLowerCase(); + return msg.contains('database is locked') || + msg.contains('database busy') || + msg.contains('sqlite_busy'); + } + /// Helper to quote SQLite identifiers safely. static String quoteIdentifier(String id) { return '"${id.replaceAll('"', '""')}"'; diff --git a/lib/core/database/sqlite_connection_pool.dart b/lib/core/database/sqlite_connection_pool.dart index 672e63dc..6ed042e4 100644 --- a/lib/core/database/sqlite_connection_pool.dart +++ b/lib/core/database/sqlite_connection_pool.dart @@ -6,8 +6,18 @@ import 'package:querya_desktop/core/storage/local_db.dart'; /// Session policy for pooled SQLite connections. enum SqliteSessionMode { + /// Tree catalog, overview, and Table Browser SELECT/COUNT. readOnly, + + /// SQL editor. Must not be shared with Table Browser. readWrite, + + /// Table Browser Save (own `Database` so BEGIN/ATTACH do not leak). + tableWrite, +} + +extension SqliteSessionModeReadOnly on SqliteSessionMode { + bool get isReadOnlySession => this == SqliteSessionMode.readOnly; } /// Factory to build a connected SQLite connection. @@ -63,8 +73,7 @@ class SqliteConnectionPool { final Map _pool = {}; final PoolEntryLock _creationLock = PoolEntryLock(); - String keyFor(int? id, SqliteSessionMode mode) => - '${id ?? 0}::${mode.name}'; + String keyFor(int? id, SqliteSessionMode mode) => '${id ?? 0}::${mode.name}'; Future acquire( ConnectionRow row, { @@ -161,6 +170,20 @@ class SqliteConnectionPool { _removeEntryClosing(k); } + /// Force-closes every session mode for this connection id. + void interruptAllModes(ConnectionRow row) { + for (final mode in SqliteSessionMode.values) { + interrupt(row, mode: mode); + } + } + + /// Whether the SQL-editor slot currently has an open transaction. + Future hasOpenSqlTransaction(ConnectionRow row) async { + final entry = _pool[keyFor(row.id, SqliteSessionMode.readWrite)]; + if (entry == null || !entry.connection.isConnected) return false; + return await entry.connection.inOpenTransaction() ?? false; + } + Future disconnectAll() async { final entries = _pool.values.toList(); _pool.clear(); diff --git a/lib/core/database/sqlite_service.dart b/lib/core/database/sqlite_service.dart index 09385875..ad430701 100644 --- a/lib/core/database/sqlite_service.dart +++ b/lib/core/database/sqlite_service.dart @@ -3,7 +3,11 @@ import 'package:querya_desktop/core/database/sqlite_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; export 'sqlite_connection_pool.dart' - show SqliteLease, SqliteSessionMode, SqliteConnectionPool; + show + SqliteLease, + SqliteSessionMode, + SqliteSessionModeReadOnly, + SqliteConnectionPool; Future _defaultCreateAndConnect( ConnectionRow row, { @@ -11,12 +15,22 @@ Future _defaultCreateAndConnect( }) async { final conn = SqliteConnection.fromConnectionRow( row, - readOnly: mode == SqliteSessionMode.readOnly, + readOnly: sqliteOpenReadOnly(row: row, mode: mode), ); await conn.connect(); return conn; } +/// Whether this pool slot must open the file with `SQLITE_OPEN_READONLY`. +/// +/// Form **Read only** (`useSSL`) always opens `SQLITE_OPEN_READONLY`, even +/// for [SqliteSessionMode.tableWrite]. Browse already uses `readOnly`. +bool sqliteOpenReadOnly({ + required ConnectionRow row, + required SqliteSessionMode mode, +}) => + mode.isReadOnlySession || row.useSSL; + /// Global SQLite connection pool service (singleton). class SqliteService { SqliteService._() @@ -44,5 +58,12 @@ class SqliteService { }) => _pool.interrupt(row, mode: mode); + /// Force-closes every session mode for this connection id. + void interruptAllModes(ConnectionRow row) => _pool.interruptAllModes(row); + + /// Whether the SQL-editor read-write slot has an open transaction. + Future hasOpenSqlTransaction(ConnectionRow row) => + _pool.hasOpenSqlTransaction(row); + Future disconnectAll() => _pool.disconnectAll(); } diff --git a/lib/core/database/sqlite_sql.dart b/lib/core/database/sqlite_sql.dart new file mode 100644 index 00000000..ca535468 --- /dev/null +++ b/lib/core/database/sqlite_sql.dart @@ -0,0 +1,225 @@ +// Comment-strip + first-keyword classification for SQLite (not a full parser). + +/// Strips `--` line comments and `/* */` block comments. +String sqliteStripSqlComments(String sql) { + return sql + .replaceAll(RegExp(r'--.*$', multiLine: true), '') + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); +} + +/// Whether [sql] should use `rawQuery` and is allowed on a read-only connection. +/// +/// `WITH` is read-only only when the statement after the CTEs is `SELECT` / +/// `VALUES` / `EXPLAIN`. `PRAGMA name = value` is a write. +bool sqliteSqlIsReadOnlyQuery(String sql) { + final stmt = sqliteStripSqlComments(sql).trim().toLowerCase(); + if (stmt.isEmpty) return true; + + final scan = _SqliteSqlScan(stmt); + scan.skipWsAndSemis(); + final kw = scan.peekKeyword(); + if (kw == null) return false; + + switch (kw) { + case 'select': + case 'values': + case 'explain': + return true; + case 'pragma': + return !_pragmaAssigns(scan); + case 'with': + if (!_skipWithCtes(scan)) return false; + final inner = scan.peekKeyword(); + return inner == 'select' || inner == 'values' || inner == 'explain'; + default: + return false; + } +} + +bool _pragmaAssigns(_SqliteSqlScan scan) { + scan.eatKeyword('pragma'); + var depth = 0; + while (scan.i < scan.s.length) { + final c = scan.s[scan.i]; + if (c == "'" || c == '"' || c == '`') { + scan.skipQuoted(c); + continue; + } + if (c == '[') { + scan.skipUntil(']'); + continue; + } + if (c == '(') { + depth++; + scan.i++; + continue; + } + if (c == ')') { + if (depth > 0) depth--; + scan.i++; + continue; + } + if (c == ';' && depth == 0) break; + if (c == '=' && depth == 0) return true; + scan.i++; + } + return false; +} + +bool _skipWithCtes(_SqliteSqlScan scan) { + if (!scan.eatKeyword('with')) return false; + scan.eatKeyword('recursive'); + while (true) { + if (!scan.skipIdent()) return false; + scan.skipWs(); + if (scan.peek('(')) { + if (!scan.skipBalancedParen()) return false; + } + if (!scan.eatKeyword('as')) return false; + if (scan.eatKeyword('not')) { + if (!scan.eatKeyword('materialized')) return false; + } else { + scan.eatKeyword('materialized'); + } + if (!scan.skipBalancedParen()) return false; + scan.skipWs(); + if (scan.peek(',')) { + scan.i++; + continue; + } + return true; + } +} + +class _SqliteSqlScan { + _SqliteSqlScan(this.s); + + final String s; + int i = 0; + + void skipWs() { + while (i < s.length) { + final c = s.codeUnitAt(i); + if (c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d) { + i++; + continue; + } + break; + } + } + + void skipWsAndSemis() { + skipWs(); + while (i < s.length && s[i] == ';') { + i++; + skipWs(); + } + } + + bool peek(String ch) { + skipWs(); + return i < s.length && s[i] == ch; + } + + String? peekKeyword() { + skipWs(); + if (i >= s.length) return null; + final c = s.codeUnitAt(i); + if (!_isIdentStart(c)) return null; + var j = i + 1; + while (j < s.length && _isIdentPart(s.codeUnitAt(j))) { + j++; + } + return s.substring(i, j); + } + + bool eatKeyword(String kw) { + skipWs(); + if (i + kw.length > s.length) return false; + if (s.substring(i, i + kw.length) != kw) return false; + final end = i + kw.length; + if (end < s.length && _isIdentPart(s.codeUnitAt(end))) return false; + i = end; + return true; + } + + bool skipIdent() { + skipWs(); + if (i >= s.length) return false; + final c = s[i]; + if (c == '"' || c == '`') { + skipQuoted(c); + return true; + } + if (c == '[') { + skipUntil(']'); + return true; + } + if (!_isIdentStart(s.codeUnitAt(i))) return false; + i++; + while (i < s.length && _isIdentPart(s.codeUnitAt(i))) { + i++; + } + return true; + } + + bool skipBalancedParen() { + skipWs(); + if (i >= s.length || s[i] != '(') return false; + var depth = 0; + while (i < s.length) { + final c = s[i]; + if (c == "'" || c == '"' || c == '`') { + skipQuoted(c); + continue; + } + if (c == '[') { + skipUntil(']'); + continue; + } + if (c == '(') { + depth++; + i++; + continue; + } + if (c == ')') { + depth--; + i++; + if (depth == 0) return true; + continue; + } + i++; + } + return false; + } + + void skipQuoted(String quote) { + i++; + while (i < s.length) { + if (s[i] == quote) { + i++; + if (i < s.length && s[i] == quote) { + i++; + continue; + } + return; + } + i++; + } + } + + void skipUntil(String end) { + i++; + while (i < s.length) { + if (s[i] == end) { + i++; + return; + } + i++; + } + } +} + +bool _isIdentStart(int c) => (c >= 0x61 && c <= 0x7a) || c == 0x5f; + +bool _isIdentPart(int c) => _isIdentStart(c) || (c >= 0x30 && c <= 0x39); diff --git a/lib/core/database/stream_take_drain.dart b/lib/core/database/stream_take_drain.dart new file mode 100644 index 00000000..9353bb28 --- /dev/null +++ b/lib/core/database/stream_take_drain.dart @@ -0,0 +1,25 @@ +/// Keeps the first [cap] events from [stream], then drains the rest. +/// +/// Cancelling a MySQL `rowsStream` at the cap leaves COM_QUERY unread +/// (`waitingCommandResponse`); the next execute waits for +/// `connectionEstablished` and can time out. Draining lets the producer +/// finish (EOF) so the session returns to ready. +Future<({List items, bool truncated})> takeThenDrain( + Stream stream, + int cap, { + Future Function(int count)? onProgress, +}) async { + final items = []; + var truncated = false; + await for (final item in stream) { + if (items.length >= cap) { + truncated = true; + continue; + } + items.add(item); + if (onProgress != null) { + await onProgress(items.length); + } + } + return (items: items, truncated: truncated); +} diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index 772e21de..e8790fc1 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + /// Supported SQL dialects for DML mutation generation. enum SqlDialect { postgres, @@ -140,6 +142,19 @@ 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); + + /// Public alias of [_isBinaryType] for grid display / validators. + static bool isBinaryType(String dataTypeName) => _isBinaryType(dataTypeName); + static const String kNullSentinel = '\u0000__QUERYA_NULL__\u0000'; /// Formats a cell string value safely as an SQL literal or `NULL`. @@ -157,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'; @@ -256,6 +278,7 @@ abstract final class TableMutationEngine { required List> insertedRows, required Set deletedRowIndices, Map? columnDataTypes, + Map? columnMeta, }) { final statements = []; final tableRef = quoteQualifiedTable( @@ -316,14 +339,31 @@ abstract final class TableMutationEngine { for (var c = 0; c < columns.length; c++) { final colName = columns[c]; + final meta = columnMeta?[colName]; + if (meta?.omitOnInsert == true) continue; + + final cellVal = c < row.length ? row[c] : (meta == null ? 'NULL' : ''); + if (meta != null && + _isBlankInsertCell(cellVal) && + (meta.hasServerDefault || meta.isNullable)) { + continue; + } + final quotedCol = quoteIdentifier(colName, dialect); - final cellVal = c < row.length ? row[c] : 'NULL'; - final colType = columnDataTypes?[colName]; + final colType = columnDataTypes?[colName] ?? meta?.dataType; colNames.add(quotedCol); values.add(formatLiteral(cellVal, dialect, dataTypeName: colType)); } - final sql = 'INSERT INTO $tableRef (${colNames.join(', ')}) VALUES (${values.join(', ')})'; + final String sql; + if (colNames.isEmpty) { + sql = dialect == SqlDialect.mysql + ? 'INSERT INTO $tableRef () VALUES ()' + : 'INSERT INTO $tableRef DEFAULT VALUES'; + } else { + sql = + 'INSERT INTO $tableRef (${colNames.join(', ')}) VALUES (${values.join(', ')})'; + } statements.add( TableMutationStatement( type: MutationType.insert, @@ -407,4 +447,9 @@ abstract final class TableMutationEngine { return clauses.join(' AND '); } + + static bool _isBlankInsertCell(String value) { + if (value == kNullSentinel) return true; + return value.trim().isEmpty; + } } diff --git a/lib/core/database/table_schema_meta.dart b/lib/core/database/table_schema_meta.dart index 0043a62e..73d41cc3 100644 --- a/lib/core/database/table_schema_meta.dart +++ b/lib/core/database/table_schema_meta.dart @@ -7,6 +7,8 @@ class TableColumnMeta { this.isPrimaryKey = false, this.primaryKeyPosition, this.defaultValue, + this.omitOnInsert = false, + this.hasServerDefault = false, }); final String name; @@ -16,6 +18,12 @@ class TableColumnMeta { final int? primaryKeyPosition; final String? defaultValue; + /// Never send on INSERT (GENERATED ALWAYS, identity always, AUTO_INCREMENT). + final bool omitOnInsert; + + /// Empty INSERT cells should omit the column so DEFAULT / serial applies. + final bool hasServerDefault; + Map toJson() => { 'name': name, 'dataType': dataType, @@ -23,6 +31,8 @@ class TableColumnMeta { 'isPrimaryKey': isPrimaryKey, 'primaryKeyPosition': primaryKeyPosition, 'defaultValue': defaultValue, + 'omitOnInsert': omitOnInsert, + 'hasServerDefault': hasServerDefault, }; factory TableColumnMeta.fromJson(Map json) => @@ -33,6 +43,8 @@ class TableColumnMeta { isPrimaryKey: json['isPrimaryKey'] as bool? ?? false, primaryKeyPosition: json['primaryKeyPosition'] as int?, defaultValue: json['defaultValue'] as String?, + omitOnInsert: json['omitOnInsert'] as bool? ?? false, + hasServerDefault: json['hasServerDefault'] as bool? ?? false, ); } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 053241e7..15178249 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -107,6 +107,7 @@ abstract final class AppSettingsKeys { static const postgresSqlStmtTimeoutSeconds = 'postgres_sql_stmt_timeout_seconds'; static const mysqlSqlStmtTimeoutSeconds = 'mysql_sql_stmt_timeout_seconds'; + static const sqliteSqlStmtTimeoutSeconds = 'sqlite_sql_stmt_timeout_seconds'; static const sqlResultMaxRows = 'sql_result_max_rows'; static const sqlEditorFontSizePoints = 'sql_editor_font_size_points'; static const sqlHistoryMaxEntries = 'sql_history_max_entries'; @@ -197,6 +198,29 @@ class AppSettings { SqlWorkspaceSettingsRevision.bump(); } + /// `null` = no application-level timeout (`executeWithTimeout` is unbounded). + Future getSqliteSqlStmtTimeoutSeconds() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + ); + if (v == null || v.isEmpty) return null; + return int.tryParse(v); + } + + Future setSqliteSqlStmtTimeoutSeconds(int? seconds) async { + if (seconds == null) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + seconds.toString(), + ); + } + SqlWorkspaceSettingsRevision.bump(); + } + /// Max rows loaded into the result grid for PostgreSQL / MySQL workspaces. Future getSqlResultMaxRows() async { final v = await LocalDb.instance.getAppSetting( diff --git a/lib/core/unsaved_work_guard.dart b/lib/core/unsaved_work_guard.dart new file mode 100644 index 00000000..7f745245 --- /dev/null +++ b/lib/core/unsaved_work_guard.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Confirms before Home / Close / tree navigation discards SQL, staged grid +/// edits, or a dirty document editor. Returns true when it is safe to tear +/// down the current view. +Future confirmDiscardUnsavedWorkIfNeeded( + material.BuildContext context, +) async { + if (!UnsavedWorkRegistry.instance.hasUnsaved) return true; + final confirmed = await showDiscardUnsavedWorkDialog(context); + return confirmed == true; +} + +/// Prompt when leaving a workspace that has unsaved SQL, table, or document edits. +Future showDiscardUnsavedWorkDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (ctx) { + return QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Unsaved changes').semiBold().large(), + const Gap(8), + const Text( + 'You have unsaved SQL, staged table edits, or document ' + 'changes. Continuing will discard them.', + ).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const Text('Discard'), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/core/unsaved_work_registry.dart b/lib/core/unsaved_work_registry.dart index 8511a9df..7f02855a 100644 --- a/lib/core/unsaved_work_registry.dart +++ b/lib/core/unsaved_work_registry.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -/// Process-wide probes for unsaved SQL / staged grid edits. +/// Process-wide probes for unsaved SQL, staged grid edits, and document editors. /// /// Used by the in-app updater (and similar quit paths) to warn before /// discarding work. Owners register a probe and must unregister on dispose. diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 6ad7ed61..8b43a12c 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -165,7 +165,10 @@ ConnectionRow? _buildConnectionRow( authSource = uri.queryParameters['authSource'] ?? uri.queryParameters['authsource']; - if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { + if (type == 'postgresql' || + type == 'mysql' || + type == 'mongodb' || + type == 'redis') { connectionString = url; } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index e0b193be..3e74b305 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -679,13 +679,8 @@ class ConnectionsPanelState extends State { Future _removeConnection(int id) async { await MongoService.instance.disconnectByConnectionId(id); await ExtensionDriverSession.instance.disconnect(id); - SqliteService.instance.interrupt( + SqliteService.instance.interruptAllModes( ConnectionRow(id: id, type: 'sqlite', name: '', createdAt: ''), - mode: SqliteSessionMode.readOnly, - ); - SqliteService.instance.interrupt( - ConnectionRow(id: id, type: 'sqlite', name: '', createdAt: ''), - mode: SqliteSessionMode.readWrite, ); await LocalDb.instance.removeConnection(id); await _loadData(); @@ -706,25 +701,15 @@ class ConnectionsPanelState extends State { _expandedConnections.remove(id); }); if (conn.type == 'postgresql') { - PostgresService.instance.interrupt(conn, - database: conn.databaseName ?? 'postgres', - mode: PgSessionMode.readOnly); - PostgresService.instance.interrupt(conn, - database: conn.databaseName ?? 'postgres', - mode: PgSessionMode.readWrite); + PostgresService.instance.interruptAllModes(conn, + database: conn.databaseName ?? 'postgres'); } else if (conn.type == 'mysql') { - MysqlService.instance.interrupt(conn, - database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); - MysqlService.instance.interrupt(conn, - database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); + MysqlService.instance.interruptAllModes(conn, + database: conn.databaseName ?? ''); } else if (conn.type == 'sqlite') { - SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readOnly); - SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readWrite); + SqliteService.instance.interruptAllModes(conn); } else if (conn.type == 'redis') { - final redisConn = RedisService.instance.getConnection(id); - if (redisConn != null) { - await RedisService.instance.disconnect(redisConn); - } + await RedisService.instance.disconnectByConnectionId(id); } else if (conn.type == 'mongodb') { await MongoService.instance.disconnectByConnectionId(id); } diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index c53787e3..9e65594d 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -73,15 +73,10 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { _error = null; }); try { - // Use a temporary connection so we don't kill the main view's connection. - final c = widget.connection; - final conn = RedisConnection( + // Temporary probe (id: -1) so we don't touch RedisService / workspace sockets. + final conn = RedisConnection.fromConnectionRow( + widget.connection, id: -1, - name: 'sidebar_probe', - host: c.host ?? 'localhost', - port: c.port ?? 6379, - username: c.username, - password: c.password, ); await conn.connect(); final raw = await conn.info(); diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index d15c7dc5..08c6769f 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -149,12 +149,14 @@ class _SqliteConnectionFormContentState path: _pathController.text.trim(), readOnly: _readOnly, ); - final ok = await conn.testConnection(); + final result = await conn.testConnection(); if (!mounted) return; - if (ok) { + if (result.ok) { _showTestResult('success'); } else { - _showTestResult('error:Failed to open SQLite database.'); + _showTestResult( + 'error:${result.error ?? 'Failed to open SQLite database.'}', + ); } } catch (e) { if (!mounted) return; @@ -162,8 +164,19 @@ class _SqliteConnectionFormContentState } } - void _save() { + Future _save() async { if (!_formValidNotifier.value) return; + final path = _pathController.text.trim(); + if (!_isEditing) { + try { + await SqliteConnection.createFileIfMissing(path); + } catch (e) { + if (!mounted) return; + _showTestResult('error:$e'); + return; + } + } + if (!mounted) return; final initial = widget.initial; final row = ConnectionRow( id: initial?.id, diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index a84997d5..18170f37 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -28,13 +28,17 @@ import 'package:querya_desktop/features/connections/new_connection_url_dialog.da import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; import 'package:querya_desktop/features/main_screen/querya_status_bar.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'main_screen_workspace_state.dart'; @@ -262,7 +266,17 @@ class _MainScreenState extends State { return false; } + Future _allowUnsavedNavigation() { + return confirmDiscardUnsavedWorkIfNeeded(context); + } + void _onConnectionSelected(ConnectionRow connection) { + unawaited(_selectConnection(connection)); + } + + Future _selectConnection(ConnectionRow connection) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; final prevId = _workspace.value.activeConnection?.id; _workspace.value = _workspace.value.selectConnection(connection); if (prevId != connection.id) { @@ -282,6 +296,42 @@ class _MainScreenState extends State { String name, PostgresObjectKind kind, ) { + unawaited(_selectPostgresObject( + connection, + database, + schema, + name, + kind, + )); + } + + Future _selectPostgresObject( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) async { + final ws = _workspace.value; + final cur = ws.selectedPostgresObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.schema == schema && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; + if (!await confirmOpenPostgresTableIfSqlTx( + context, + connection: connection, + database: database, + kind: kind, + )) { + return; + } + if (!mounted) return; _workspace.value = _workspace.value.selectPostgresObject( connection, database, @@ -297,6 +347,34 @@ class _MainScreenState extends State { String name, MysqlObjectKind kind, ) { + unawaited(_selectMysqlObject(connection, database, name, kind)); + } + + Future _selectMysqlObject( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + ) async { + final ws = _workspace.value; + final cur = ws.selectedMysqlObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; + if (!await confirmOpenMysqlTableIfSqlTx( + context, + connection: connection, + database: database, + kind: kind, + )) { + return; + } + if (!mounted) return; _workspace.value = _workspace.value.selectMysqlObject( connection, database, @@ -306,10 +384,38 @@ class _MainScreenState extends State { } void _onRedisDatabaseSelected(ConnectionRow connection, int database) { + unawaited(_selectRedisDatabase(connection, database)); + } + + Future _selectRedisDatabase( + ConnectionRow connection, + int database, + ) async { + final ws = _workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeRedisDb == database) { + return; + } + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectRedisDb(connection, database); } void _onMongoDBDatabaseSelected(ConnectionRow connection, String database) { + unawaited(_selectMongoDatabase(connection, database)); + } + + Future _selectMongoDatabase( + ConnectionRow connection, + String database, + ) async { + final ws = _workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeMongoDB == database) { + return; + } + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectMongoDb(connection, database); } @@ -320,6 +426,24 @@ class _MainScreenState extends State { String? name, PostgresObjectKind? kind, }) { + unawaited(_openPostgresSqlWorkspace( + connection, + database: database, + schema: schema, + name: name, + kind: kind, + )); + } + + Future _openPostgresSqlWorkspace( + ConnectionRow connection, { + String? database, + String? schema, + String? name, + PostgresObjectKind? kind, + }) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.openPostgresSqlWorkspace( connection, seedDatabase: database, @@ -330,6 +454,12 @@ class _MainScreenState extends State { } void _onMysqlOpenSqlWorkspace(ConnectionRow connection) { + unawaited(_openMysqlSqlWorkspace(connection)); + } + + Future _openMysqlSqlWorkspace(ConnectionRow connection) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.openMysqlSqlWorkspace(connection); } @@ -338,6 +468,31 @@ class _MainScreenState extends State { String name, SqliteObjectKind kind, ) { + unawaited(_selectSqliteObject(connection, name, kind)); + } + + Future _selectSqliteObject( + ConnectionRow connection, + String name, + SqliteObjectKind kind, + ) async { + final ws = _workspace.value; + final cur = ws.selectedSqliteObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; + if (!await confirmOpenSqliteTableIfSqlTx( + context, + connection: connection, + kind: kind, + )) { + return; + } + if (!mounted) return; _workspace.value = _workspace.value.selectSqliteObject( connection, name, @@ -346,6 +501,12 @@ class _MainScreenState extends State { } void _onSqliteOpenSqlWorkspace(ConnectionRow connection) { + unawaited(_openSqliteSqlWorkspace(connection)); + } + + Future _openSqliteSqlWorkspace(ConnectionRow connection) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.openSqliteSqlWorkspace(connection); } @@ -354,6 +515,23 @@ class _MainScreenState extends State { String database, String name, ) { + unawaited(_selectExtensionObject(connection, database, name)); + } + + Future _selectExtensionObject( + ConnectionRow connection, + String database, + String name, + ) async { + final ws = _workspace.value; + final cur = ws.selectedExtensionObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.name == name; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectExtensionObject( connection, database, @@ -449,7 +627,7 @@ class _MainScreenState extends State { _onSqliteOpenSqlWorkspace(connection); default: if (ExtensionDriverCatalog.isExtensionDriverConnection(connection)) { - _workspace.value = _workspace.value.selectConnection(connection); + unawaited(_selectConnection(connection)); } } } @@ -500,10 +678,22 @@ class _MainScreenState extends State { } void _onGoHome() { + unawaited(_goHome()); + } + + Future _goHome() async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = MainScreenWorkspaceState.empty; QueryaShellStatus.instance.clear(); } + Future _closeWorkspace() async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; + _workspace.value = _workspace.value.unselectActiveObject(); + } + bool _hasCloseableWorkspace(MainScreenWorkspaceState ws) { return ws.selectedPostgresObject != null || ws.selectedMysqlObject != null || @@ -602,9 +792,7 @@ class _MainScreenState extends State { onGoHome: workspace.activeConnection != null ? _onGoHome : null, onCloseWorkspace: _hasCloseableWorkspace(workspace) - ? () { - _workspace.value = _workspace.value.unselectActiveObject(); - } + ? () => unawaited(_closeWorkspace()) : null, onConnect: workspace.activeConnection?.id != null ? () => _connectionsPanelKey.currentState @@ -1019,6 +1207,47 @@ class _MainContentSplitState extends State<_MainContentSplit> unawaited(_restoreState()); } + Future _restoreLastSelectedObject() async { + final ws = widget.workspace.value; + final conn = ws.activeConnection; + final pg = ws.lastSelectedPostgresObject; + if (conn != null && conn.type == 'postgresql' && pg != null) { + if (!await confirmOpenPostgresTableIfSqlTx( + context, + connection: conn, + database: pg.database, + kind: pg.kind, + )) { + return; + } + } + if (!mounted) return; + final sq = ws.lastSelectedSqliteObject; + if (conn != null && conn.type == 'sqlite' && sq != null) { + if (!await confirmOpenSqliteTableIfSqlTx( + context, + connection: conn, + kind: sq.kind, + )) { + return; + } + } + if (!mounted) return; + final my = ws.lastSelectedMysqlObject; + if (conn != null && conn.type == 'mysql' && my != null) { + if (!await confirmOpenMysqlTableIfSqlTx( + context, + connection: conn, + database: my.database, + kind: my.kind, + )) { + return; + } + } + if (!mounted) return; + widget.workspace.value = widget.workspace.value.restoreLastSelectedObject(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -1257,8 +1486,7 @@ class _MainContentSplitState extends State<_MainContentSplit> widget.workspace.value.unselectActiveObject(); }, onRestoreLastSelectedObject: () { - widget.workspace.value = - widget.workspace.value.restoreLastSelectedObject(); + unawaited(_restoreLastSelectedObject()); }, isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 4157fc87..6cf76d76 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -291,6 +291,7 @@ class _WorkspacePanelState extends State { tableName: my.name, isView: my.kind == MysqlObjectKind.view, onNavigateHome: widget.onNavigateHome, + isReadOnly: widget.isReadOnly, ); } } @@ -343,6 +344,7 @@ class _WorkspacePanelState extends State { key: ValueKey('redis_${activeConn.id}_db_$redisDb'), connectionRow: activeConn, database: redisDb, + isReadOnly: widget.isReadOnly, ), ); break; @@ -368,6 +370,7 @@ class _WorkspacePanelState extends State { tableName: sq.name, isView: sq.kind == SqliteObjectKind.view, onNavigateHome: widget.onNavigateHome, + isReadOnly: widget.isReadOnly, ), ); break; diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 3622039c..4d504e87 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -3,8 +3,10 @@ import 'dart:math' show min; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/actions/querya_schema_object.dart'; import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -181,6 +183,15 @@ class _MongoCollectionsViewState extends material.State { } Future _dropCollection(String name) async { + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropCollection, + targetName: name, + commandPreview: 'db.$name.drop()', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + try { await MongoService.instance.dropCollection( widget.connection, diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 20e7686d..31aeaf14 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -176,6 +178,15 @@ class _MongoDatabasesViewState extends State { Future _dropDatabase(String name) async { if (_connection == null) return; + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropDatabase, + targetName: name, + commandPreview: '{ dropDatabase: 1 }', + connectionName: widget.connectionRow.name, + ); + if (!mounted || !confirmed) return; + try { await MongoService.instance.executeCommand( _connection!, diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 6cc9dafa..f1834853 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -1,10 +1,15 @@ -import 'dart:convert'; +import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; -import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -51,9 +56,10 @@ class _MongoDocumentEditorState extends material.State { void initState() { super.initState(); _controller = material.TextEditingController( - text: _prettyJson(widget.document), + text: mongoDocumentToEjson(widget.document), ); _controller.addListener(_onTextChanged); + UnsavedWorkRegistry.instance.register(this, () => _dirty || _saving); } @override @@ -87,7 +93,7 @@ class _MongoDocumentEditorState extends material.State { } final doc = rows.first; _controller.removeListener(_onTextChanged); - _controller.text = _prettyJson(doc); + _controller.text = mongoDocumentToEjson(doc); _controller.addListener(_onTextChanged); setState(() { _dirty = false; @@ -101,11 +107,18 @@ class _MongoDocumentEditorState extends material.State { @override void dispose() { + UnsavedWorkRegistry.instance.unregister(this); _controller.removeListener(_onTextChanged); _controller.dispose(); super.dispose(); } + Future _onBack() async { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; + if (!mounted) return; + widget.onBack?.call(); + } + void _onTextChanged() { if (!_dirty) { setState(() => _dirty = true); @@ -114,8 +127,8 @@ class _MongoDocumentEditorState extends material.State { void _format() { try { - final parsed = json.decode(_controller.text) as Map; - _controller.text = _prettyJson(parsed); + final parsed = mongoDocumentFromEjson(_controller.text); + _controller.text = mongoDocumentToEjson(parsed); setState(() => _error = null); } catch (e) { setState(() => _error = 'Invalid JSON: $e'); @@ -131,15 +144,18 @@ class _MongoDocumentEditorState extends material.State { Map parsed; try { - parsed = json.decode(_controller.text) as Map; + parsed = mongoDocumentFromEjson(_controller.text); } catch (e) { setState(() => _error = 'Invalid JSON: $e'); return; } - // Remove _id from the update payload (can't change _id) - final updateDoc = Map.from(parsed); - updateDoc.remove('_id'); + // replaceOne drops keys the user deleted. `_id` stays the original BSON + // value even if the editor JSON changed it. + final replacement = mongoFullDocumentReplacement( + parsed: parsed, + originalId: id, + ); setState(() { _saving = true; @@ -148,12 +164,12 @@ class _MongoDocumentEditorState extends material.State { }); try { - await MongoService.instance.updateDocument( + await MongoService.instance.replaceDocument( widget.connection, widget.database, widget.collection, {'_id': id}, - {r'$set': updateDoc}, + replacement, ); if (!mounted) return; setState(() { @@ -179,6 +195,16 @@ class _MongoDocumentEditorState extends material.State { final id = widget.document['_id']; if (id == null) return; + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.deleteDocument, + targetName: id.toString(), + commandPreview: + 'db.${widget.collection}.deleteOne({ _id: ${id.toString()} })', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + setState(() { _deleting = true; _error = null; @@ -203,14 +229,6 @@ class _MongoDocumentEditorState extends material.State { } } - String _prettyJson(Map doc) { - try { - return const JsonEncoder.withIndent(' ').convert(doc); - } catch (_) { - return doc.toString(); - } - } - @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -233,7 +251,7 @@ class _MongoDocumentEditorState extends material.State { child: Row( children: [ material.InkWell( - onTap: widget.onBack, + onTap: () => unawaited(_onBack()), borderRadius: material.BorderRadius.circular(6), child: material.Padding( padding: const material.EdgeInsets.all(4), diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index e38f6e27..98700e7c 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,10 +1,12 @@ import 'dart:async' show unawaited; -import 'dart:convert'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -45,6 +47,7 @@ class _MongoDocumentsViewState extends material.State { final _filterController = material.TextEditingController(); Map? _activeFilter; + String? _emptyFilterHint; @override void initState() { @@ -92,6 +95,11 @@ class _MongoDocumentsViewState extends material.State { _totalCount = count; _documents = docs; _loading = false; + _emptyFilterHint = docs.isEmpty && + _activeFilter != null && + mongoFilterNeedsObjectIdHint(_activeFilter!) + ? kMongoFilterIdStringHint + : null; }); } catch (e) { if (mounted) { @@ -109,7 +117,7 @@ class _MongoDocumentsViewState extends material.State { _activeFilter = null; } else { try { - _activeFilter = json.decode(text) as Map; + _activeFilter = mongoFilterFromJson(text); } catch (e) { setState(() { _error = 'Invalid JSON filter: $e'; @@ -117,6 +125,7 @@ class _MongoDocumentsViewState extends material.State { return; } } + _emptyFilterHint = null; _skip = 0; _load(); } @@ -124,6 +133,7 @@ class _MongoDocumentsViewState extends material.State { void _clearFilter() { _filterController.clear(); _activeFilter = null; + _emptyFilterHint = null; _skip = 0; _load(); } @@ -161,22 +171,25 @@ class _MongoDocumentsViewState extends material.State { } Future _inspectField(Map doc, String field) async { - if (field == '_id') return; + if (mongoFieldIsReadOnly(field)) return; final id = doc['_id']; if (id == null) return; await showGridCellInspectorDialog( context: context, columnName: field, initialValue: mongoFieldToDisplay(doc[field]), - dataTypeName: 'MongoDB field', + dataTypeName: doc[field]?.runtimeType.toString() ?? 'MongoDB field', onSaveToDatabase: (value) async { + mongoAssertFieldEditable(field); await MongoService.instance.updateDocument( widget.connection, widget.database, widget.collection, {'_id': id}, { - r'$set': {field: mongoDisplayToValue(value)} + r'$set': { + field: mongoDisplayToValue(value, original: doc[field]), + } }, ); if (!mounted) return; @@ -188,6 +201,17 @@ class _MongoDocumentsViewState extends material.State { Future _deleteDocument(Map doc) async { final id = doc['_id']; if (id == null) return; + + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.deleteDocument, + targetName: id.toString(), + commandPreview: + 'db.${widget.collection}.deleteOne({ _id: ${id.toString()} })', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + try { await MongoService.instance.deleteDocument( widget.connection, @@ -242,7 +266,8 @@ class _MongoDocumentsViewState extends material.State { ? material.Center( child: material.Padding( padding: const material.EdgeInsets.all(48), - child: const Text('No documents found').muted(), + child: Text(_emptyFilterHint ?? 'No documents found') + .muted(), ), ) : material.ListView.separated( @@ -287,7 +312,9 @@ class _MongoDocumentsViewState extends material.State { material.Expanded( child: TextField( controller: _filterController, - placeholder: const Text('Filter (JSON) e.g. {"name": "John"}'), + placeholder: const Text( + r'Filter (JSON / EJSON) e.g. {"_id": {"$oid": "…"}}', + ), onSubmitted: (_) => _applyFilter(), ), ), diff --git a/lib/features/mongodb/mongo_ejson.dart b/lib/features/mongodb/mongo_ejson.dart new file mode 100644 index 00000000..d8afb8be --- /dev/null +++ b/lib/features/mongodb/mongo_ejson.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:mongo_dart/mongo_dart.dart'; + +/// Pretty-print a MongoDB document as relaxed Extended JSON. +/// +/// ObjectId, DateTime, NumberLong, NumberDecimal, BinData, and Timestamp +/// stay typed (`$oid`, `$date`, …) instead of falling back to `toString()`. +String mongoDocumentToEjson(Map doc) { + final ejson = EJsonCodec.deserialize( + BsonCodec.serialize(Map.from(doc)), + relaxed: true, + ); + return const JsonEncoder.withIndent(' ').convert(ejson); +} + +/// Parses Extended JSON (canonical or relaxed) back into Dart BSON values. +Map mongoDocumentFromEjson(String text) { + final decoded = json.decode(text); + if (decoded is! Map) { + throw const FormatException('Document JSON must be an object'); + } + return EJsonCodec.eJson2Doc(Map.from(decoded)); +} + +/// Hint when a find filter's `_id` is still a JSON string (not ObjectId). +const kMongoFilterIdStringHint = + 'No documents matched. If `_id` is an ObjectId, use ' + r'{"_id": {"$oid": "…"}} or a 24-character hex `_id`.'; + +/// Parses a find filter: Extended JSON (`$oid`, `$date`, …) plus a 24-char +/// hex `_id` string wrapped as [ObjectId]. +Map mongoFilterFromJson(String text) { + final decoded = json.decode(text); + if (decoded is! Map) { + throw const FormatException('Filter JSON must be an object'); + } + final raw = Map.from(decoded); + Map doc; + try { + doc = EJsonCodec.eJson2Doc(Map.from(raw)); + } catch (_) { + doc = raw; + } + final id = doc['_id']; + if (id is String && ObjectId.isValidHexId(id)) { + doc['_id'] = ObjectId.fromHexString(id); + } + return doc; +} + +/// True when `_id` is still a JSON string (hex wrap did not apply). +bool mongoFilterNeedsObjectIdHint(Map filter) { + return filter['_id'] is String; +} + +/// Full-document Save payload for [DbCollection.replaceOne]. +/// +/// Parsed JSON minus any `_id` the user typed, then `_id` locked to +/// [originalId]. Keys absent from [parsed] (including nested ones) are not in +/// the replacement, so they are dropped — unlike `$set`, which only merges. +Map mongoFullDocumentReplacement({ + required Map parsed, + required Object originalId, +}) { + final out = Map.from(parsed); + out['_id'] = originalId; + return out; +} diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index ddb1580a..ef7938e8 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -1,8 +1,11 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -192,7 +195,14 @@ class _MongoExplorerViewState extends material.State { return list; } - void _onCrumbTap(_Crumb crumb) { + Future _onCrumbTap(_Crumb crumb) async { + final leavesDocument = _selectedDocument != null && + crumb.level != _Level.document && + crumb.level != _Level.stats; + if (leavesDocument) { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; + if (!mounted) return; + } if (_showStats && crumb.level != _Level.stats) { setState(() => _showStats = false); } @@ -275,7 +285,7 @@ class _MongoExplorerViewState extends material.State { // Breadcrumb bar _BreadcrumbBar( crumbs: _crumbs, - onCrumbTap: _onCrumbTap, + onCrumbTap: (crumb) => unawaited(_onCrumbTap(crumb)), onRefresh: () => setState(() => _refreshToken++), onStats: () => setState(() => _showStats = !_showStats), ), diff --git a/lib/features/mongodb/mongo_field_codec.dart b/lib/features/mongodb/mongo_field_codec.dart index 30df2b5c..401cb3b8 100644 --- a/lib/features/mongodb/mongo_field_codec.dart +++ b/lib/features/mongodb/mongo_field_codec.dart @@ -1,36 +1,153 @@ import 'dart:convert'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; + +/// Mongo forbids `$set` of `_id` on an existing document. +bool mongoFieldIsReadOnly(String field) => field == '_id'; + +/// Throws if [field] cannot be updated with `$set` on an existing document. +void mongoAssertFieldEditable(String field) { + if (mongoFieldIsReadOnly(field)) { + throw StateError( + 'MongoDB forbids \$set of _id on an existing document', + ); + } +} + /// Display string for a MongoDB document field in the Cell Inspector. String mongoFieldToDisplay(Object? value) { if (value == null) return 'NULL'; - if (value is Map || value is List) { - try { - return const JsonEncoder.withIndent(' ').convert(value); - } catch (_) { - return value.toString(); + if (value is String) return value; + if (value is bool || value is num) return value.toString(); + if (value is ObjectId) return value.oid; + if (value is DateTime) return value.toUtc().toIso8601String(); + try { + final ejson = EJsonCodec.deserialize( + BsonCodec.serialize({'v': value}), + relaxed: true, + )['v']; + if (ejson is Map || ejson is List) { + return const JsonEncoder.withIndent(' ').convert(ejson); } + return ejson?.toString() ?? value.toString(); + } catch (_) { + return value.toString(); } - if (value is bool || value is num) return value.toString(); - if (value is String) return value; - return value.toString(); } -/// Parses an inspector string back into a BSON-JSON-friendly Dart value. -Object? mongoDisplayToValue(String text) { +/// Parses an inspector string back into a BSON value of [original]'s type. +/// +/// Strings stay strings even when they look like numbers. ObjectId, DateTime, +/// Timestamp, Decimal128, Int32/Int64, and Double keep their width. +Object? mongoDisplayToValue(String text, {Object? original}) { if (text == 'NULL') return null; + + if (original is String) return text; + final trimmed = text.trim(); + + if (original is bool) { + if (trimmed == 'true') return true; + if (trimmed == 'false') return false; + throw FormatException('Expected true or false, got "$trimmed"'); + } + if (original is int) return int.parse(trimmed); + if (original is double) return double.parse(trimmed); + if (original is ObjectId) return _parseObjectId(trimmed); + if (original is DateTime) return _parseDateTime(trimmed); + if (original is Timestamp) return _parseTimestamp(trimmed); + if (original is Map || original is List) { + return _parseWrappedEjson(trimmed); + } + if (original != null) { + return _parseKeepingRuntimeType(trimmed, original); + } + if (trimmed.isEmpty) return ''; if (trimmed == 'true') return true; if (trimmed == 'false') return false; - final asNum = num.tryParse(trimmed); - if (asNum != null) return asNum; if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { - return json.decode(trimmed); + return _parseWrappedEjson(trimmed); } catch (_) { return text; } } return text; } + +ObjectId _parseObjectId(String trimmed) { + if (ObjectId.isValidHexId(trimmed)) { + return ObjectId.fromHexString(trimmed); + } + final fromLiteral = _objectIdFromShellLiteral(trimmed); + if (fromLiteral != null) return fromLiteral; + if (trimmed.startsWith('{')) { + final decoded = json.decode(trimmed); + if (decoded is Map && decoded[r'$oid'] is String) { + return ObjectId.fromHexString(decoded[r'$oid'] as String); + } + } + throw FormatException('Not a valid ObjectId: $trimmed'); +} + +ObjectId? _objectIdFromShellLiteral(String trimmed) { + const prefix = 'ObjectId('; + if (!trimmed.startsWith(prefix) || !trimmed.endsWith(')')) return null; + final inner = trimmed.substring(prefix.length, trimmed.length - 1).trim(); + if (inner.length < 2) return null; + final quote = inner[0]; + if (quote != '"' && quote != "'") return null; + if (!inner.endsWith(quote)) return null; + final hex = inner.substring(1, inner.length - 1); + if (!ObjectId.isValidHexId(hex)) return null; + return ObjectId.fromHexString(hex); +} + +DateTime _parseDateTime(String trimmed) { + if (trimmed.startsWith('{')) { + final value = _parseWrappedEjson(trimmed); + if (value is DateTime) return value; + } + return DateTime.parse(trimmed).toUtc(); +} + +Timestamp _parseTimestamp(String trimmed) { + if (trimmed.startsWith('{')) { + final value = _parseWrappedEjson(trimmed); + if (value is Timestamp) return value; + } + throw FormatException('Not a valid Timestamp: $trimmed'); +} + +Object? _parseWrappedEjson(String trimmed) { + return mongoDocumentFromEjson('{"v": $trimmed}')['v']; +} + +Object? _parseKeepingRuntimeType(String trimmed, Object original) { + final typeName = original.runtimeType.toString(); + final candidates = [ + if (trimmed.startsWith('{') || trimmed.startsWith('[')) '{"v": $trimmed}', + '{"v": {"\$numberLong": ${json.encode(trimmed)}}}', + '{"v": {"\$numberDecimal": ${json.encode(trimmed)}}}', + '{"v": {"\$numberInt": ${json.encode(trimmed)}}}', + '{"v": {"\$numberDouble": ${json.encode(trimmed)}}}', + ]; + if (typeName == 'Int64') { + candidates.insert(0, '{"v": {"\$numberLong": ${json.encode(trimmed)}}}'); + } + if (typeName == 'Decimal') { + candidates.insert(0, '{"v": {"\$numberDecimal": ${json.encode(trimmed)}}}'); + } + for (final wrapped in candidates) { + try { + final v = mongoDocumentFromEjson(wrapped)['v']; + if (v.runtimeType == original.runtimeType) return v; + } catch (_) {} + } + throw FormatException( + 'Could not parse "$trimmed" as $typeName', + ); +} diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 8cb7cfd2..7406447e 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -368,8 +368,10 @@ class _MysqlConnectionFormContentState ).muted().small(), const Gap(4), const Text( - 'Query params: ssl-mode (disable, require), database, ' - 'sslrootcert, sslcert, sslkey.', + 'Query params: ssl-mode (disable, prefer, require, ' + 'verify_ca, verify_identity), database, ' + 'sslrootcert, sslcert, sslkey. ' + 'prefer/require encrypt; verify_* needs sslrootcert.', ).muted().small(), const Gap(8), TextField( diff --git a/lib/features/mysql/mysql_sql_tx_guard.dart b/lib/features/mysql/mysql_sql_tx_guard.dart new file mode 100644 index 00000000..8261e9c2 --- /dev/null +++ b/lib/features/mysql/mysql_sql_tx_guard.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +bool mysqlObjectOpensTableBrowser(MysqlObjectKind kind) { + return kind == MysqlObjectKind.table || kind == MysqlObjectKind.view; +} + +/// SQL-editor toolbar label for the current transaction state. +String mysqlSqlToolbarTxLabel(bool? txOpen) { + if (txOpen == null) return 'Transaction: —'; + return txOpen ? 'Transaction: open' : 'Transaction: none'; +} + +/// Confirms leaving the SQL tab (or opening a table) while a transaction is open. +Future confirmLeaveOpenMysqlTransaction( + material.BuildContext context, +) async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Open transaction'), + content: const material.Text( + 'The SQL tab has an open transaction. Leave anyway? ' + 'Uncommitted work may be lost if the session ends.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Stay'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Leave'), + ), + ], + ), + ); + return ok == true; +} + +/// Warns before opening Table Browser while SQL has an open transaction +/// on the same database. +Future confirmOpenMysqlTableIfSqlTx( + material.BuildContext context, { + required ConnectionRow connection, + required String database, + required MysqlObjectKind kind, +}) async { + if (!mysqlObjectOpensTableBrowser(kind)) return true; + final open = await MysqlService.instance.hasOpenSqlTransaction( + connection, + database: database, + ); + if (!open) return true; + if (!context.mounted) return false; + return confirmLeaveOpenMysqlTransaction(context); +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 99473c67..6699a8e3 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -7,14 +7,18 @@ import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/stream_take_drain.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; 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/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/workspace.dart'; @@ -25,12 +29,16 @@ class MysqlSqlWorkspace extends material.StatefulWidget { const MysqlSqlWorkspace({ super.key, required this.connectionRow, + this.transactionOpenNotifier, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Updated when transaction state changes (for tab-switch warnings). + final material.ValueNotifier? transactionOpenNotifier; + @override material.State createState() => _MysqlSqlWorkspaceState(); } @@ -43,6 +51,7 @@ class _MysqlSqlWorkspaceState extends material.State { SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; MysqlLease? _lease; + bool? _txOpen; int? _queryTimeoutSeconds; @@ -201,7 +210,9 @@ class _MysqlSqlWorkspaceState extends material.State { final lease = await MysqlService.instance.acquire( widget.connectionRow, database: _poolDatabaseKey(), - mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, + mode: widget.isReadOnly + ? MysqlSessionMode.readOnly + : MysqlSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -210,6 +221,22 @@ class _MysqlSqlWorkspaceState extends material.State { _lease = lease; } + void _notifyTransactionOpen() { + widget.transactionOpenNotifier?.value = _txOpen; + } + + Future _refreshTxStatus() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) setState(() => _txOpen = null); + _notifyTransactionOpen(); + return; + } + final v = await conn.inOpenTransaction(); + if (mounted) setState(() => _txOpen = v); + _notifyTransactionOpen(); + } + Duration? _statementTimeout() => _queryTimeoutSeconds == null ? null : Duration(seconds: _queryTimeoutSeconds!); @@ -225,7 +252,9 @@ class _MysqlSqlWorkspaceState extends material.State { MysqlService.instance.interrupt( widget.connectionRow, database: _poolDatabaseKey(), - mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, + mode: widget.isReadOnly + ? MysqlSessionMode.readOnly + : MysqlSessionMode.readWrite, ); } _lease?.release(); @@ -269,6 +298,8 @@ class _MysqlSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -288,8 +319,10 @@ class _MysqlSqlWorkspaceState extends material.State { } final to = _statementTimeout(); + final cap = _resultMaxRows; + final sql = injectSqlLimit(userSql, cap); final rs = - await conn.executeWithTimeout(userSql, timeout: to, iterable: true); + await conn.executeWithTimeout(sql, timeout: to, iterable: true); if (!mounted) return; @@ -299,39 +332,71 @@ class _MysqlSqlWorkspaceState extends material.State { } // Convert while streaming — no Object? matrix + isolate double-copy (#421). - final outRows = >[]; - var n = 0; - final cap = _resultMaxRows; - var truncated = false; - await for (final row in rs.rowsStream) { - if (n >= cap) { - truncated = true; - break; - } - outRows.add( + // Drain leftover rows so COM_QUERY reaches EOF (#808); do not cancel. + final taken = await takeThenDrain( + rs.rowsStream, + cap, + onProgress: (n) async { + if (n % kResultStringConvertYieldEvery == 0) { + await Future.delayed(Duration.zero); + } + }, + ); + final colList = rs.cols.toList(); + final outRows = [ + for (final row in taken.items) List.generate( row.numOfColumns, - (i) => resultCellToDisplayString(row.colAt(i)), + (i) { + final col = i < colList.length ? colList[i] : null; + return mysqlResultCellToDisplayString( + row.colAt(i), + column: col, + ); + }, ), - ); - n++; - if (n % kResultStringConvertYieldEvery == 0) { - await Future.delayed(Duration.zero); - } - } + ]; + final truncated = + taken.truncated || (sql != userSql && outRows.length >= cap); + final n = outRows.length; int? affected; if (cols.isEmpty && outRows.isEmpty) { affected = _affectedInt(rs.affectedRows); } + final target = SqlTableTargetExtractor.extract(userSql); + var pks = const []; + Map? types; + 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; + } + } + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); + setState(() { session.columns = cols; session.rows = outRows; session.affectedRows = affected; session.lastExecutedSql = userSql; + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); - session.stagingBuffer = cols.isNotEmpty + session.stagingBuffer = canSave ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; if (cols.isEmpty && outRows.isEmpty) { @@ -380,6 +445,8 @@ class _MysqlSqlWorkspaceState extends material.State { }); QueryaShellStatus.instance.endBusy(); } + } finally { + await _refreshTxStatus(); } } @@ -393,15 +460,27 @@ class _MysqlSqlWorkspaceState extends material.State { final target = session.lastExecutedSql != null ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) : null; - final tableName = target?.tableName ?? 'table'; - final schemaName = target?.schema; + if (target == null || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + )) { + return; + } + final schemaName = target.schema ?? + (widget.connectionRow.databaseName?.trim().isNotEmpty == true + ? widget.connectionRow.databaseName!.trim() + : null); setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( dialect: SqlDialect.mysql, - tableName: tableName, + tableName: target.tableName, schema: schemaName, + primaryKeys: session.resultGridPrimaryKeys, + columnDataTypes: session.resultGridColumnDataTypes, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); @@ -423,9 +502,13 @@ class _MysqlSqlWorkspaceState extends material.State { throw StateError('Could not connect to MySQL.'); } - for (final stmt in plan.statements) { - await conn.execute(stmt.sql); - } + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + final rs = await conn.execute(stmt.sql); + expectDmlMatchedRows(rs.affectedRows.toInt()); + } + }); + await _refreshTxStatus(); if (!mounted) return; final newRows = session.stagingBuffer!.effectiveRows; @@ -556,12 +639,52 @@ class _MysqlSqlWorkspaceState extends material.State { } } - Future _runTxCommand(String sql) async { - _activeSession.controller.value = material.TextEditingValue( - text: sql, - selection: material.TextSelection.collapsed(offset: sql.length), - ); - await _execute(_activeSession); + Future _runTxCommand(String cmd) async { + final session = _activeSession; + setState(() { + session.running = true; + session.error = null; + }); + try { + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) { + setState(() { + session.error = 'Could not connect to MySQL.'; + session.running = false; + }); + } + return; + } + final to = _statementTimeout(); + await conn.executeWithTimeout(cmd, timeout: to); + if (!mounted) return; + setState(() { + session.columns = []; + session.rows = []; + session.affectedRows = null; + session.statusLine = 'OK: $cmd'; + session.running = false; + }); + } on TimeoutException catch (e) { + unawaited(_lease?.connection.forceClose()); + if (mounted) { + setState(() { + session.error = e.toString(); + session.running = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + session.error = e.toString(); + session.running = false; + }); + } + } finally { + await _refreshTxStatus(); + } } @override @@ -609,16 +732,22 @@ class _MysqlSqlWorkspaceState extends material.State { }, child: material.CallbackShortcuts( bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, control: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyT, + control: true): _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): + _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyW, + control: true): () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): + () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true, shift: true): _prevTab, + const material.SingleActivator(LogicalKeyboardKey.tab, control: true): + _nextTab, + const material.SingleActivator(LogicalKeyboardKey.tab, + control: true, shift: true): _prevTab, const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_activeSession.running) unawaited(_execute(_activeSession)); }, @@ -667,7 +796,8 @@ class _MysqlSqlWorkspaceState extends material.State { SqlQueryTabBar( sessions: _sessions, selectedIndex: _activeSessionIndex, - onSelect: (index) => setState(() => _activeSessionIndex = index), + onSelect: (index) => + setState(() => _activeSessionIndex = index), onAdd: _addNewTab, onClose: _sessions.length > 1 ? (index) => unawaited(_closeTab(index)) @@ -716,9 +846,13 @@ class _MysqlSqlWorkspaceState extends material.State { ); } : null, - onBegin: session.running ? null : () => _runTxCommand('START TRANSACTION;'), - onCommit: session.running ? null : () => _runTxCommand('COMMIT;'), - onRollback: session.running ? null : () => _runTxCommand('ROLLBACK;'), + txOpen: _txOpen, + onBegin: session.running + ? null + : () => _runTxCommand('START TRANSACTION'), + onCommit: session.running ? null : () => _runTxCommand('COMMIT'), + onRollback: + session.running ? null : () => _runTxCommand('ROLLBACK'), ), const Divider(height: 1), Expanded( @@ -753,8 +887,15 @@ class _MysqlSqlWorkspaceState extends material.State { affectedRows: session.affectedRows, statusLine: session.statusLine, stagingBuffer: session.stagingBuffer, - onApplyChanges: - widget.isReadOnly ? null : () => _applyStagedChanges(session), + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => _applyStagedChanges(session), isSaving: session.savingChanges, ), ), @@ -772,6 +913,7 @@ class _MysqlSqlToolbar extends material.StatelessWidget { required this.onQueryTimeoutChanged, required this.onOpenPreferences, this.onOpenHistory, + required this.txOpen, required this.onBegin, required this.onCommit, required this.onRollback, @@ -783,6 +925,7 @@ class _MysqlSqlToolbar extends material.StatelessWidget { final void Function(int?) onQueryTimeoutChanged; final VoidCallback onOpenPreferences; final VoidCallback? onOpenHistory; + final bool? txOpen; final VoidCallback? onBegin; final VoidCallback? onCommit; final VoidCallback? onRollback; @@ -797,10 +940,13 @@ class _MysqlSqlToolbar extends material.StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, children: [ - material.Row( + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, children: [ const Text('Query').semiBold().small(), - const Spacer(), + Text(mysqlSqlToolbarTxLabel(txOpen)).muted().small(), OutlineButton( size: ButtonSize.small, onPressed: onOpenHistory, @@ -811,7 +957,6 @@ class _MysqlSqlToolbar extends material.StatelessWidget { ), child: const Text('History'), ), - const Gap(8), OutlineButton( onPressed: onExecute, leading: running diff --git a/lib/features/mysql/mysql_table_utils.dart b/lib/features/mysql/mysql_table_utils.dart index 045db086..59db41d6 100644 --- a/lib/features/mysql/mysql_table_utils.dart +++ b/lib/features/mysql/mysql_table_utils.dart @@ -1,3 +1,19 @@ +import 'package:querya_desktop/core/database/mysql_connection.dart'; + +/// Browse SELECT for Table Browser. PK columns get `ORDER BY` so LIMIT/OFFSET +/// is stable on InnoDB. Empty [primaryKeys] keeps unordered scan (views / no PK). +String mysqlBrowseDataSql({ + required String qualifiedFrom, + required List primaryKeys, + required int limit, + required int offset, +}) { + final order = primaryKeys.isEmpty + ? '' + : ' ORDER BY ${primaryKeys.map(MysqlConnection.quoteIdentifier).join(', ')}'; + return 'SELECT * FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; +} + /// Whether [sql] is allowed for the table browser "custom SQL" path (read-only SELECT). bool isAllowedMysqlSelectQuery(String sql) { final t = sql.trim(); diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index ad22e733..e25c14f3 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -4,9 +4,13 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/storage/app_settings.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/mysql/mysql_sql_editor_dialog.dart'; import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; @@ -24,6 +28,7 @@ class MysqlTableView extends material.StatefulWidget { this.isView = false, this.limit = _defaultLimit, this.onNavigateHome, + this.isReadOnly = false, }); final ConnectionRow connectionRow; @@ -33,6 +38,9 @@ class MysqlTableView extends material.StatefulWidget { final int limit; final VoidCallback? onNavigateHome; + /// Title-bar session lock: no staging / Save / `tableWrite` acquire. + final bool isReadOnly; + @override material.State createState() => _MysqlTableViewState(); } @@ -55,7 +63,9 @@ class _MysqlTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; List _primaryKeys = []; Map _columnDataTypes = {}; + Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; String get _tableTitle => '${widget.database}.${widget.tableName}'; @@ -66,6 +76,8 @@ class _MysqlTableViewState extends material.State { isView: widget.isView, customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, + readOnly: widget.isReadOnly, + schemaError: _schemaError, ); String _qualifiedFrom() { @@ -75,7 +87,12 @@ class _MysqlTableViewState extends material.State { } String _browseDataSql() { - return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; + return mysqlBrowseDataSql( + qualifiedFrom: _qualifiedFrom(), + primaryKeys: _primaryKeys, + limit: widget.limit, + offset: _offset, + ); } @override @@ -96,6 +113,8 @@ class _MysqlTableViewState extends material.State { _resetStaging(); _disconnectCurrent(interruptIfBusy: true); _connectAndLoad(); + } else if (oldWidget.isReadOnly != widget.isReadOnly) { + _syncStagingToReadOnly(); } } @@ -111,16 +130,40 @@ class _MysqlTableViewState extends material.State { _stagingBuffer = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } + void _syncStagingToReadOnly() { + if (widget.isReadOnly) { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + } else if (_columnNames.isNotEmpty) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: _editingEnabled, + ); + } + if (mounted) setState(() {}); + } + void _disconnectCurrent({bool interruptIfBusy = false}) { if (interruptIfBusy && _loading) { MysqlService.instance.interrupt( widget.connectionRow, database: widget.database, - mode: MysqlSessionMode.readWrite, + mode: MysqlSessionMode.readOnly, + ); + } + if (interruptIfBusy && _isSaving) { + MysqlService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.tableWrite, ); } _lease?.release(); @@ -146,7 +189,7 @@ class _MysqlTableViewState extends material.State { final lease = await MysqlService.instance.acquire( widget.connectionRow, database: widget.database, - mode: MysqlSessionMode.readWrite, + mode: MysqlSessionMode.readOnly, ); if (!mounted) { lease.release(); @@ -164,23 +207,26 @@ class _MysqlTableViewState extends material.State { } } - static int _asInt(String? v) { - if (v == null) return 0; - return int.tryParse(v) ?? 0; - } - List _resultColumns(IResultSet rs) { return rs.cols.map((c) => c.name.isNotEmpty ? c.name : 'col').toList(); } Future>> _resultRowsAsync(IResultSet rs) async { + final colList = rs.cols.toList(); final out = >[]; var n = 0; for (final row in rs.rows) { out.add( List.generate( row.numOfColumns, - (i) => resultCellToDisplayString(row.colAt(i)), + (i) { + final col = i < colList.length ? colList[i] : null; + return mysqlResultCellToDisplayString( + row.colAt(i), + column: col, + schemaDataType: col == null ? null : _columnDataTypes[col.name], + ); + }, ), ); n++; @@ -191,6 +237,24 @@ class _MysqlTableViewState extends material.State { return out; } + Future _withTableWrite( + Future Function(MysqlConnection conn) fn, + ) async { + if (widget.isReadOnly) { + throw StateError('MySQL session is read-only'); + } + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.tableWrite, + ); + try { + return await fn(lease.connection); + } finally { + lease.release(); + } + } + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -203,20 +267,29 @@ class _MysqlTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema( + final loaded = await loadTableViewSchema( + () => conn.getTableSchema( database: widget.database, table: widget.tableName, - ); + ), + ); + final schema = loaded.schema; + if (schema != null) { _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); - } catch (_) { + _columnMeta = columnMetaFromSchema(schema); + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -250,33 +323,38 @@ class _MysqlTableViewState extends material.State { _loading = true; _error = null; }); - final from = _qualifiedFrom(); - final countSql = 'SELECT COUNT(*) AS c FROM $from'; - final dataSql = _browseDataSql(); try { - int totalRows; - if (refreshCount || _totalRowCount == null) { - final countRs = await conn.execute(countSql); - totalRows = - countRs.rows.isEmpty ? 0 : _asInt(countRs.rows.first.colAt(0)); - } else { - totalRows = _totalRowCount!; + await _ensureSchema(conn); + if (!mounted) return; + + int? totalRows = _totalRowCount; + if (refreshCount || totalRows == null) { + try { + totalRows = await conn.estimateTableRows( + database: widget.database, + table: widget.tableName, + ); + } catch (_) { + totalRows = null; + } } + final dataSql = _browseDataSql(); final result = await conn.execute(dataSql); if (!mounted) return; - await _ensureSchema(conn); - if (!mounted) return; - final colNames = _resultColumns(result); final stringRows = await _resultRowsAsync(result); if (!mounted) return; + final shown = stringRows.length; + if (totalRows != null && shown > 0 && totalRows < _offset + shown) { + totalRows = null; + } setState(() { _columnNames = colNames; _rows = stringRows; - _rowsOnPage = stringRows.length; + _rowsOnPage = shown; if (refreshCount || _totalRowCount == null) { _totalRowCount = totalRows; } @@ -312,7 +390,9 @@ class _MysqlTableViewState extends material.State { _error = null; }); try { - final result = await conn.execute(sql); + final result = await conn.execute( + injectSqlLimit(sql, kDefaultSqlResultMaxRows), + ); if (!mounted) return; final stringRows = await _resultRowsAsync(result); if (!mounted) return; @@ -437,6 +517,8 @@ class _MysqlTableViewState extends material.State { customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, + readOnly: widget.isReadOnly, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (reason != null) return '$pag · $reason'; @@ -446,6 +528,8 @@ class _MysqlTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; if (_customSqlActive) { await _fetchCustom(); } else { @@ -462,6 +546,7 @@ class _MysqlTableViewState extends material.State { } Future _applyStagedChanges() async { + if (widget.isReadOnly) return; final buffer = _stagingBuffer; if (buffer == null || !buffer.isDirty || _isSaving) return; setState(() => _isSaving = true); @@ -473,23 +558,19 @@ class _MysqlTableViewState extends material.State { schema: widget.database, primaryKeys: _primaryKeys, columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { - final conn = _connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to MySQL.'); - } - await conn.execute('START TRANSACTION'); - try { - for (final stmt in plan.statements) { - await conn.execute(stmt.sql); + await _withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to MySQL.'); } - await conn.execute('COMMIT'); - } catch (e) { - try { - await conn.execute('ROLLBACK'); - } catch (_) {} - rethrow; - } + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + final rs = await conn.execute(stmt.sql); + expectDmlMatchedRows(rs.affectedRows.toInt()); + } + }); + }); }, ); if (!mounted) return; diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 04333594..7d8f9821 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_workspace.dart'; import 'package:querya_desktop/features/mysql/mysql_stats_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -40,11 +41,13 @@ class MysqlWorkspaceHome extends material.StatefulWidget { class _MysqlWorkspaceHomeState extends material.State { int _tab = 0; + late final material.ValueNotifier _sqlTxNotifier; int _lastAppliedSqlTabToken = 0; @override void initState() { super.initState(); + _sqlTxNotifier = material.ValueNotifier(null); _lastAppliedSqlTabToken = widget.sqlTabRequestToken; } @@ -65,8 +68,18 @@ class _MysqlWorkspaceHomeState extends material.State { } } + @override + void dispose() { + _sqlTxNotifier.dispose(); + super.dispose(); + } + Future _selectTab(int i) async { if (i == _tab) return; + if (_tab == 1 && i == 0 && _sqlTxNotifier.value == true) { + final ok = await confirmLeaveOpenMysqlTransaction(context); + if (!ok) return; + } setState(() => _tab = i); } @@ -103,7 +116,8 @@ class _MysqlWorkspaceHomeState extends material.State { material.Icons.table_chart_outlined, size: 14, ), - child: Text('Return to ${widget.lastSelectedMysqlObject!.name}'), + child: + Text('Return to ${widget.lastSelectedMysqlObject!.name}'), ), ], const Spacer(), @@ -127,6 +141,7 @@ class _MysqlWorkspaceHomeState extends material.State { MysqlSqlWorkspace( key: ValueKey('mysql_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + transactionOpenNotifier: _sqlTxNotifier, isReadOnly: widget.isReadOnly, ), ], diff --git a/lib/features/postgresql/postgres_result_utils.dart b/lib/features/postgresql/postgres_result_utils.dart index a6aaf18d..da0e494b 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_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index d8a51601..aa2f0455 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -2,36 +2,10 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Returns true if [sql] is allowed to run (read-only: SELECT / WITH). -bool isAllowedPostgresSelectQuery(String sql) { - final t = sql.trim(); - if (t.isEmpty) return false; - final lower = t.toLowerCase(); - const blocked = [ - 'insert ', - 'update ', - 'delete ', - 'drop ', - 'truncate ', - 'alter ', - 'create ', - 'grant ', - 'revoke ', - 'call ', - 'execute ', - 'copy ', - ]; - for (final b in blocked) { - if (lower.contains(b)) return false; - } - return lower.startsWith('select') || - lower.startsWith('with') || - lower.startsWith('('); -} - /// Dialog to view/edit SQL and run it. /// [initialSql] — text shown when opening; [browseSql] — "Reset" restores table browse query. Future showPostgresSqlEditorDialog({ diff --git a/lib/features/postgresql/postgres_sql_tx_guard.dart b/lib/features/postgresql/postgres_sql_tx_guard.dart new file mode 100644 index 00000000..4c05d9eb --- /dev/null +++ b/lib/features/postgresql/postgres_sql_tx_guard.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Kinds that open Table Browser (SELECT on a dedicated session). +bool postgresObjectOpensTableBrowser(PostgresObjectKind kind) { + return kind == PostgresObjectKind.table || + kind == PostgresObjectKind.view || + kind == PostgresObjectKind.materializedView; +} + +/// Confirms leaving the SQL tab (or opening a table) while a transaction is open. +Future confirmLeaveOpenPostgresTransaction( + material.BuildContext context, +) async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Open transaction'), + content: const material.Text( + 'The SQL tab has an open transaction. Leave anyway? ' + 'Uncommitted work may be lost if the session ends.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Stay'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Leave'), + ), + ], + ), + ); + return ok == true; +} + +/// Warns before opening Table Browser on a database whose SQL editor has a tx. +Future confirmOpenPostgresTableIfSqlTx( + material.BuildContext context, { + required ConnectionRow connection, + required String database, + required PostgresObjectKind kind, +}) async { + if (!postgresObjectOpensTableBrowser(kind)) return true; + final open = await PostgresService.instance.hasOpenSqlTransaction( + connection, + database: database, + ); + if (!open) return true; + if (!context.mounted) return false; + return confirmLeaveOpenPostgresTransaction(context); +} diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index fcaf0544..b6a06fdc 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'; @@ -425,6 +426,8 @@ class _PostgresSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -443,14 +446,15 @@ class _PostgresSqlWorkspaceState extends material.State { return; } + final to = _statementTimeout(); if (!_autocommit) { final inTx = await conn.inOpenTransaction() ?? false; if (!inTx && !shouldSkipImplicitBegin(sql)) { - sql = 'BEGIN;\n$sql'; + // Separate execute: Parse cannot take `BEGIN;` + the next statement. + await conn.execute('BEGIN', timeout: to); } } - final to = _statementTimeout(); final result = await conn.execute(sql, timeout: to); if (!mounted) return; @@ -473,16 +477,47 @@ class _PostgresSqlWorkspaceState extends material.State { n++; } - // Adaptive convert offloads to background compute for large row sets (#522). - 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 []; + Map? types; + 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; + } + } + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); setState(() { session.columns = cols; session.rows = outRows; session.affectedRows = result.affectedRows; session.lastExecutedSql = userSql; + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); - session.stagingBuffer = cols.isNotEmpty + session.stagingBuffer = canSave ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; if (cols.isEmpty && outRows.isEmpty) { @@ -554,15 +589,23 @@ class _PostgresSqlWorkspaceState extends material.State { final target = session.lastExecutedSql != null ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) : null; - final tableName = target?.tableName ?? 'table'; - final schemaName = target?.schema; + if (target == null || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + )) { + return; + } setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( dialect: SqlDialect.postgres, - tableName: tableName, - schema: schemaName, + tableName: target.tableName, + schema: target.schema ?? 'public', + primaryKeys: session.resultGridPrimaryKeys, + columnDataTypes: session.resultGridColumnDataTypes, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); @@ -584,9 +627,16 @@ class _PostgresSqlWorkspaceState extends material.State { throw StateError('Could not connect to PostgreSQL.'); } - final txSql = plan.toTransactionSql(); final to = _statementTimeout(); - await conn.execute(txSql, timeout: to); + await runPostgresStatementsInTransaction( + (sql) async { + final result = await conn.execute(sql, timeout: to); + if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { + expectDmlMatchedRows(result.affectedRows); + } + }, + plan.statements.map((s) => s.sql), + ); if (!mounted) return; final newRows = session.stagingBuffer!.effectiveRows; @@ -907,8 +957,15 @@ class _PostgresSqlWorkspaceState extends material.State { affectedRows: session.affectedRows, statusLine: session.statusLine, stagingBuffer: session.stagingBuffer, - onApplyChanges: - widget.isReadOnly ? null : () => _applyStagedChanges(session), + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => _applyStagedChanges(session), isSaving: session.savingChanges, ), ), @@ -965,14 +1022,14 @@ class _SqlToolbar extends material.StatelessWidget { crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, children: [ - material.Row( + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, children: [ const Text('Query').semiBold().small(), - const Gap(12), Text('DB: $sessionDatabase').muted().small(), - const Gap(12), Text(_txLabel()).muted().small(), - const Spacer(), OutlineButton( size: ButtonSize.small, onPressed: onOpenHistory, @@ -983,7 +1040,6 @@ class _SqlToolbar extends material.StatelessWidget { ), child: const Text('History'), ), - const Gap(8), OutlineButton( onPressed: onExecute, leading: running diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index 19355bb4..a91d252e 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -1,9 +1,26 @@ /// 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. const kPostgresBrowseDefaultRowLimit = 200; +/// Browse SELECT for Table Browser. PK columns get `ORDER BY` so LIMIT/OFFSET +/// is stable. Empty [primaryKeys] keeps unordered scan (views / no PK). +String postgresBrowseDataSql({ + required String qualifiedFrom, + required List primaryKeys, + required int limit, + required int offset, +}) { + final order = primaryKeys.isEmpty + ? '' + : ' ORDER BY ${primaryKeys.map(quotePostgresIdentifier).join(', ')}'; + return 'SELECT * FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; +} + /// `SELECT *` template matching [PostgresTableView] browse (same limit/offset). String postgresBrowseSelectSql({ required String schema, @@ -22,11 +39,281 @@ 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. +/// +/// Classifies the **first** statement after comments (not substring `contains`). +/// Allows `SELECT` / `WITH … SELECT` / `TABLE` / `VALUES` / `(SELECT …)` only. +/// Multi-statement scripts are rejected. +bool isAllowedPostgresSelectQuery(String sql) { + final t = sql.trim(); + if (t.isEmpty) return false; + + final statements = _splitPostgresStatements(t) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty && !_postgresFragmentIsOnlyComments(s)) + .toList(); + if (statements.length != 1) return false; + + return _postgresStatementIsReadOnlySelect(statements.first); +} + +List _splitPostgresStatements(String sql) { + final masked = _maskPostgresComments(maskSqlLiteralsForLimitScan(sql)); + final statements = []; + var start = 0; + for (var i = 0; i < masked.length; i++) { + if (masked[i] == ';') { + statements.add(sql.substring(start, i)); + start = i + 1; + } + } + statements.add(sql.substring(start)); + return statements; } + +String _maskPostgresComments(String sql) { + final out = StringBuffer(); + var i = 0; + while (i < sql.length) { + if (i + 1 < sql.length && sql[i] == '-' && sql[i + 1] == '-') { + while (i < sql.length && sql[i] != '\n') { + out.write(' '); + i++; + } + continue; + } + if (i + 1 < sql.length && sql[i] == '/' && sql[i + 1] == '*') { + out.write(' '); + i += 2; + while (i < sql.length) { + if (i + 1 < sql.length && sql[i] == '*' && sql[i + 1] == '/') { + out.write(' '); + i += 2; + break; + } + out.write(' '); + i++; + } + continue; + } + out.write(sql[i]); + i++; + } + return out.toString(); +} + +bool _postgresFragmentIsOnlyComments(String sql) { + var s = sql.trim(); + while (s.isNotEmpty) { + s = stripLeadingWhitespaceAndLineComments(s).trimLeft(); + if (s.startsWith('/*')) { + final end = s.indexOf('*/'); + if (end == -1) return true; + s = s.substring(end + 2).trim(); + continue; + } + if (s.isEmpty || s == ';') return true; + return false; + } + return true; +} + +bool _postgresStatementIsReadOnlySelect(String sql) { + final code = _postgresLeadingCode(sql); + if (code.isEmpty) return false; + final u = code.toUpperCase(); + if (_startsWithKeyword(u, 'SELECT')) return true; + if (_startsWithKeyword(u, 'VALUES')) return true; + if (_startsWithKeyword(u, 'TABLE')) return true; + if (_startsWithKeyword(u, 'WITH')) { + return _postgresWithIsSelect(_stripPostgresComments(code)); + } + return false; +} + +String _postgresLeadingCode(String sql) { + var s = sql.trim(); + while (s.isNotEmpty) { + s = stripLeadingWhitespaceAndLineComments(s).trimLeft(); + if (s.startsWith('/*')) { + final end = s.indexOf('*/'); + if (end == -1) return ''; + s = s.substring(end + 2).trimLeft(); + continue; + } + if (s.startsWith('(')) { + s = s.substring(1).trimLeft(); + continue; + } + return s; + } + return ''; +} + +bool _startsWithKeyword(String upper, String kw) { + if (!upper.startsWith(kw)) return false; + if (upper.length == kw.length) return true; + final next = upper.codeUnitAt(kw.length); + final isIdent = (next >= 0x41 && next <= 0x5a) || + next == 0x5f || + (next >= 0x30 && next <= 0x39); + return !isIdent; +} + +String _stripPostgresComments(String sql) { + return sql + .replaceAll(RegExp(r'--.*$', multiLine: true), '') + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); +} + +/// `WITH` is read-only only when the statement after the CTEs is SELECT/VALUES/TABLE. +bool _postgresWithIsSelect(String sql) { + final scan = _PgSqlScan(sql.toLowerCase()); + if (!_skipPostgresWithCtes(scan)) return false; + final inner = scan.peekKeyword(); + return inner == 'select' || inner == 'values' || inner == 'table'; +} + +bool _skipPostgresWithCtes(_PgSqlScan scan) { + if (!scan.eatKeyword('with')) return false; + scan.eatKeyword('recursive'); + while (true) { + if (!scan.skipIdent()) return false; + scan.skipWs(); + if (scan.peek('(')) { + if (!scan.skipBalancedParen()) return false; + } + if (!scan.eatKeyword('as')) return false; + if (scan.eatKeyword('not')) { + if (!scan.eatKeyword('materialized')) return false; + } else { + scan.eatKeyword('materialized'); + } + if (!scan.skipBalancedParen()) return false; + scan.skipWs(); + if (scan.peek(',')) { + scan.i++; + continue; + } + return true; + } +} + +class _PgSqlScan { + _PgSqlScan(this.s); + + final String s; + int i = 0; + + void skipWs() { + while (i < s.length) { + final c = s.codeUnitAt(i); + if (c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d) { + i++; + continue; + } + break; + } + } + + bool peek(String ch) { + skipWs(); + return i < s.length && s[i] == ch; + } + + String? peekKeyword() { + skipWs(); + if (i >= s.length) return null; + final c = s.codeUnitAt(i); + if (!_pgIdentStart(c)) return null; + var j = i + 1; + while (j < s.length && _pgIdentPart(s.codeUnitAt(j))) { + j++; + } + return s.substring(i, j); + } + + bool eatKeyword(String kw) { + skipWs(); + if (i + kw.length > s.length) return false; + if (s.substring(i, i + kw.length) != kw) return false; + final end = i + kw.length; + if (end < s.length && _pgIdentPart(s.codeUnitAt(end))) return false; + i = end; + return true; + } + + bool skipIdent() { + skipWs(); + if (i >= s.length) return false; + if (s[i] == '"') { + i++; + while (i < s.length) { + if (s[i] == '"') { + i++; + if (i < s.length && s[i] == '"') { + i++; + continue; + } + return true; + } + i++; + } + return true; + } + if (!_pgIdentStart(s.codeUnitAt(i))) return false; + i++; + while (i < s.length && _pgIdentPart(s.codeUnitAt(i))) { + i++; + } + return true; + } + + bool skipBalancedParen() { + skipWs(); + if (i >= s.length || s[i] != '(') return false; + var depth = 0; + while (i < s.length) { + final c = s[i]; + if (c == "'" || c == '"') { + final q = c; + i++; + while (i < s.length) { + if (s[i] == q) { + i++; + if (i < s.length && s[i] == q) { + i++; + continue; + } + break; + } + i++; + } + continue; + } + if (c == '(') { + depth++; + i++; + continue; + } + if (c == ')') { + depth--; + i++; + if (depth == 0) return true; + continue; + } + i++; + } + return false; + } +} + +bool _pgIdentStart(int c) => (c >= 0x61 && c <= 0x7a) || c == 0x5f; + +bool _pgIdentPart(int c) => _pgIdentStart(c) || (c >= 0x30 && c <= 0x39); diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index ade09570..3d4c7c0a 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -2,11 +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'; @@ -55,7 +59,7 @@ class _PostgresTableViewState extends material.State { /// Rows on the current page (same as _rows.length when not loading). int _rowsOnPage = 0; - /// Total rows in table/view (from COUNT(*)). + /// Planner estimate (`reltuples`); `null` when unknown or stale. int? _totalRowCount; /// Zero-based offset for LIMIT/OFFSET pagination. @@ -68,7 +72,9 @@ class _PostgresTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; List _primaryKeys = []; Map _columnDataTypes = {}; + Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; String get _tableTitle => '${widget.schema}.${widget.tableName}'; @@ -80,6 +86,7 @@ class _PostgresTableViewState extends material.State { isMaterializedView: widget.isMaterializedView, customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaError: _schemaError, ); @override @@ -116,7 +123,9 @@ class _PostgresTableViewState extends material.State { _stagingBuffer = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } @@ -125,7 +134,14 @@ class _PostgresTableViewState extends material.State { PostgresService.instance.interrupt( widget.connectionRow, database: widget.database, - mode: PgSessionMode.readWrite, + mode: PgSessionMode.readOnly, + ); + } + if (interruptIfBusy && _isSaving) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.tableWrite, ); } _lease?.release(); @@ -151,8 +167,7 @@ class _PostgresTableViewState extends material.State { final lease = await PostgresService.instance.acquire( widget.connectionRow, database: widget.database, - // Custom SQL + REFRESH MATERIALIZED VIEW need a read-write session. - mode: PgSessionMode.readWrite, + mode: PgSessionMode.readOnly, ); if (!mounted) { lease.release(); @@ -170,18 +185,30 @@ class _PostgresTableViewState extends material.State { } } - static int _asInt(dynamic v) { - if (v == null) return 0; - if (v is int) return v; - if (v is BigInt) return v.toInt(); - if (v is num) return v.toInt(); - return int.tryParse(v.toString()) ?? 0; - } - String _browseDataSql() { final schemaQ = quotePostgresIdentifier(widget.schema); final tableQ = quotePostgresIdentifier(widget.tableName); - return 'SELECT * FROM $schemaQ.$tableQ LIMIT ${widget.limit} OFFSET $_offset'; + return postgresBrowseDataSql( + qualifiedFrom: '$schemaQ.$tableQ', + primaryKeys: _primaryKeys, + limit: widget.limit, + offset: _offset, + ); + } + + Future _withTableWrite( + Future Function(PostgresConnection conn) fn, + ) async { + final lease = await PostgresService.instance.acquire( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.tableWrite, + ); + try { + return await fn(lease.connection); + } finally { + lease.release(); + } } Future _confirmDiscardIfNeeded() { @@ -196,20 +223,29 @@ class _PostgresTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView || widget.isMaterializedView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema( + final loaded = await loadTableViewSchema( + () => conn.getTableSchema( schema: widget.schema, table: widget.tableName, - ); + ), + ); + final schema = loaded.schema; + if (schema != null) { _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); - } catch (_) { + _columnMeta = columnMetaFromSchema(schema); + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -223,7 +259,29 @@ class _PostgresTableViewState extends material.State { ); } - /// [refreshCount] runs `COUNT(*)` (e.g. first load or Refresh). Pagination only runs SELECT. + 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] re-reads `reltuples` (e.g. first load or Refresh). Pagination only runs SELECT. Future _fetch({bool refreshCount = false}) async { final conn = _connection; if (conn == null || !conn.isConnected) { @@ -244,42 +302,42 @@ class _PostgresTableViewState extends material.State { _loading = true; _error = null; }); - final schemaQ = quotePostgresIdentifier(widget.schema); - final tableQ = quotePostgresIdentifier(widget.tableName); - final countSql = 'SELECT COUNT(*) AS c FROM $schemaQ.$tableQ'; - final dataSql = _browseDataSql(); try { - int totalRows; - if (refreshCount || _totalRowCount == null) { - final countResult = await conn.execute(countSql); - totalRows = countResult.isEmpty ? 0 : _asInt(countResult.first[0]); - } else { - totalRows = _totalRowCount!; + await _ensureSchema(conn); + if (!mounted) return; + + int? totalRows = _totalRowCount; + if (refreshCount || totalRows == null) { + try { + totalRows = await conn.estimateTableRows( + schema: widget.schema, + table: widget.tableName, + ); + } catch (_) { + totalRows = null; + } } + final dataSql = _browseDataSql(); final result = await conn.execute(dataSql); if (!mounted) return; - await _ensureSchema(conn); - if (!mounted) return; - final colNames = List.generate( result.schema.columns.length, (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; + final shown = stringRows.length; + if (totalRows != null && shown > 0 && totalRows < _offset + shown) { + totalRows = null; + } setState(() { _columnNames = colNames; _rows = stringRows; - _rowsOnPage = stringRows.length; + _rowsOnPage = shown; if (refreshCount || _totalRowCount == null) { _totalRowCount = totalRows; } @@ -323,12 +381,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(() { @@ -371,12 +424,13 @@ class _PostgresTableViewState extends material.State { } Future _refreshMaterializedView() async { - final conn = _connection; - if (conn == null || !conn.isConnected || _loading) return; + if (_loading) return; if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; try { - await conn.refreshMaterializedView(widget.schema, widget.tableName); + await _withTableWrite((conn) { + return conn.refreshMaterializedView(widget.schema, widget.tableName); + }); if (!mounted) return; await _fetch(refreshCount: true); } catch (e) { @@ -483,6 +537,7 @@ class _PostgresTableViewState extends material.State { customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (reason != null) return '$pag · $reason'; @@ -492,6 +547,8 @@ class _PostgresTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; if (_customSqlActive) { await _fetchCustom(); } else { @@ -519,12 +576,22 @@ class _PostgresTableViewState extends material.State { schema: widget.schema, primaryKeys: _primaryKeys, columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { - final conn = _connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to PostgreSQL.'); - } - await conn.execute(plan.toTransactionSql()); + await _withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + await runPostgresStatementsInTransaction( + (sql) async { + final result = await conn.execute(sql); + if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { + expectDmlMatchedRows(result.affectedRows); + } + }, + plan.statements.map((s) => s.sql), + ); + }); }, ); if (!mounted) return; diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 095f35fa..53a6de0e 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_workspace.dart'; import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -91,27 +92,8 @@ class _PostgresWorkspaceHomeState Future _selectTab(int i) async { if (i == _tab) return; if (_tab == 1 && i == 0 && _sqlTxNotifier.value == true) { - final ok = await showAppDialog( - context: context, - builder: (ctx) => material.AlertDialog( - title: const material.Text('Open transaction'), - content: const material.Text( - 'The SQL tab has an open transaction. Leave anyway? ' - 'Uncommitted work may be lost if the session ends.', - ), - actions: [ - material.TextButton( - onPressed: () => material.Navigator.of(ctx).pop(false), - child: const material.Text('Stay'), - ), - material.TextButton( - onPressed: () => material.Navigator.of(ctx).pop(true), - child: const material.Text('Leave'), - ), - ], - ), - ); - if (ok != true) return; + final ok = await confirmLeaveOpenPostgresTransaction(context); + if (!ok) return; } setState(() => _tab = i); } diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 2d35191c..c105a8e1 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -144,6 +144,10 @@ class _PostgresConnectionFormContentState _setOrRemoveSslParam(params, 'sslrootcert', _sslRootCertController); _setOrRemoveSslParam(params, 'sslcert', _sslCertController); _setOrRemoveSslParam(params, 'sslkey', _sslKeyController); + if (!params.containsKey('sslmode') && + _sslRootCertController.text.trim().isNotEmpty) { + params['sslmode'] = 'verify-full'; + } final newUri = Uri( scheme: parsed.scheme, userInfo: parsed.userInfo.isEmpty ? null : parsed.userInfo, @@ -187,8 +191,10 @@ class _PostgresConnectionFormContentState Uri.encodeComponent(password), ]; final queryParams = { - if (sslRootCert != null && sslRootCert.isNotEmpty) + if (sslRootCert != null && sslRootCert.isNotEmpty) ...{ 'sslrootcert': sslRootCert, + 'sslmode': 'verify-full', + }, if (sslCert != null && sslCert.isNotEmpty) 'sslcert': sslCert, if (sslKey != null && sslKey.isNotEmpty) 'sslkey': sslKey, }; @@ -623,6 +629,13 @@ class _PostgresConnectionFormContentState ], ), if (_useSSL) ...[ + const Gap(8), + const Text( + 'Without a Root CA this is sslmode=require: traffic is ' + 'encrypted but the server certificate is not checked ' + '(MITM is possible). A Root CA enables verify-full. ' + 'A URI sslmode= value always wins.', + ).muted().small(), const Gap(16), material.FocusTraversalGroup( policy: material.WidgetOrderTraversalPolicy(), diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 4f98d433..592c31fa 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -184,22 +184,7 @@ class _RedisConnectionFormContentState _testResult = null; }); try { - final uri = _effectiveConnectionUri(); - final conn = RedisConnection( - id: 0, - name: _nameController.text.trim().isEmpty - ? 'test' - : _nameController.text.trim(), - host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), - port: int.tryParse(_portController.text.trim()) ?? 6379, - username: _usernameController.text.trim().isEmpty - ? null - : _usernameController.text.trim(), - password: - _passwordController.text.isEmpty ? null : _passwordController.text, - useSSL: _useSSL || _hasSslCertificateFields(), - connectionString: uri.isEmpty ? null : uri, - ); + final conn = RedisConnection.fromConnectionRow(_draftRow()); final ok = await conn.testConnection(); if (mounted) _showTestResult(ok ? 'success' : 'failed'); } catch (e) { @@ -207,8 +192,7 @@ class _RedisConnectionFormContentState } } - void _save() { - if (!_formValidNotifier.value) return; + ConnectionRow _draftRow({int? id}) { _syncUriSslParams(); final name = _nameController.text.trim(); final host = _hostController.text.trim(); @@ -216,10 +200,10 @@ class _RedisConnectionFormContentState final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; final initial = widget.initial; - final row = ConnectionRow( - id: initial?.id, + return ConnectionRow( + id: id, type: initial?.type ?? 'redis', - name: displayName, + name: displayName.isEmpty ? 'test' : displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, username: _usernameController.text.trim().isEmpty @@ -235,7 +219,11 @@ class _RedisConnectionFormContentState sortOrder: initial?.sortOrder ?? 0, createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); - material.Navigator.of(context).pop(row); + } + + void _save() { + if (!_formValidNotifier.value) return; + material.Navigator.of(context).pop(_draftRow(id: widget.initial?.id)); } @override diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 5a5e1632..af409d67 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -30,10 +33,12 @@ class RedisExplorerView extends material.StatefulWidget { super.key, required this.connectionRow, required this.database, + this.isReadOnly = false, }); final ConnectionRow connectionRow; final int database; + final bool isReadOnly; @override material.State createState() => _RedisExplorerViewState(); @@ -49,7 +54,7 @@ class _RedisExplorerViewState extends material.State { int _refreshEpoch = 0; // Navigation state - String? _selectedKey; + RedisBulkValue? _selectedKey; String? _selectedKeyType; @override @@ -65,6 +70,8 @@ class _RedisExplorerViewState extends material.State { oldWidget.database != widget.database) { _disconnectCurrent(); _connect(); + } else if (oldWidget.isReadOnly != widget.isReadOnly) { + unawaited(_connection?.applyClientReadOnly(widget.isReadOnly)); } } @@ -78,12 +85,16 @@ class _RedisExplorerViewState extends material.State { final conn = _connection; _connection = null; if (conn != null) { - conn.disconnect(); + unawaited(RedisService.instance.disconnect(conn)); } } Future _connect() async { - _disconnectCurrent(); + final old = _connection; + _connection = null; + if (old != null) { + await RedisService.instance.disconnect(old); + } if (!mounted) return; setState(() { _connecting = true; @@ -93,10 +104,16 @@ class _RedisExplorerViewState extends material.State { _showStats = false; }); try { - final conn = RedisService.instance.createConnection(widget.connectionRow); - await conn.connect(); + final conn = RedisService.instance.acquire( + widget.connectionRow, + role: RedisSessionRole.explorer, + ); + await conn.applyClientReadOnly(widget.isReadOnly); + if (!conn.isConnected) { + await conn.connect(); + } if (!mounted) { - conn.disconnect(); + unawaited(RedisService.instance.disconnect(conn)); return; } setState(() { @@ -115,7 +132,7 @@ class _RedisExplorerViewState extends material.State { // ─── Navigation helpers ───────────────────────────────────────────────── - void _navigateToKey(String key, String type) { + void _navigateToKey(RedisBulkValue key, String type) { setState(() { _selectedKey = key; _selectedKeyType = type; @@ -137,7 +154,7 @@ class _RedisExplorerViewState extends material.State { '${widget.connectionRow.name} › db${widget.database}', _Level.keys), ]; if (_selectedKey != null) { - list.add(_Crumb(_selectedKey!, _Level.key)); + list.add(_Crumb(_selectedKey!.label, _Level.key)); } if (_showStats) { list.add(const _Crumb('Statistics', _Level.stats)); @@ -226,6 +243,7 @@ class _RedisExplorerViewState extends material.State { onCrumbTap: _onCrumbTap, onRefresh: () => setState(() => _refreshEpoch++), onStats: () => setState(() => _showStats = !_showStats), + isReadOnly: widget.isReadOnly, ), const Divider(height: 1), // Content with fluid cross-fade morph between keys and stats @@ -253,13 +271,16 @@ class _RedisExplorerViewState extends material.State { // Key editor if (_selectedKey != null) { return RedisKeyEditor( - key: ValueKey('key_${widget.database}_${_selectedKey}_$_refreshEpoch'), + key: ValueKey( + 'key_${widget.database}_${_selectedKey!.label}_$_refreshEpoch'), connection: conn, database: widget.database, - keyName: _selectedKey!, - keyType: _selectedKeyType ?? 'string', + keyName: _selectedKey!.label, + keyArg: _selectedKey!.commandArg, + keyType: _selectedKeyType ?? 'unknown', onBack: _navigateToKeys, onKeyDeleted: _navigateToKeys, + isReadOnly: widget.isReadOnly, ); } @@ -269,6 +290,7 @@ class _RedisExplorerViewState extends material.State { connection: conn, database: widget.database, onKeyTap: _navigateToKey, + isReadOnly: widget.isReadOnly, ); } } @@ -281,12 +303,14 @@ class _BreadcrumbBar extends StatelessWidget { required this.onCrumbTap, required this.onRefresh, required this.onStats, + this.isReadOnly = false, }); final List<_Crumb> crumbs; final void Function(_Crumb) onCrumbTap; final VoidCallback onRefresh; final VoidCallback onStats; + final bool isReadOnly; @override material.Widget build(material.BuildContext context) { @@ -301,6 +325,14 @@ class _BreadcrumbBar extends StatelessWidget { children: [ material.Icon(material.Icons.memory_rounded, size: 18, color: cs.primary), + if (isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: cs.mutedForeground, + ), + ], const Gap(10), material.Expanded( child: material.SingleChildScrollView( diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 5a0d1415..db6a51cd 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; /// Viewer / editor for a single Redis key. Type-aware: string, hash, -/// list, set, zset. +/// list, set, zset. Stream / module / unknown types are not GET/SET. class RedisKeyEditor extends material.StatefulWidget { const RedisKeyEditor({ super.key, @@ -12,16 +15,22 @@ class RedisKeyEditor extends material.StatefulWidget { required this.database, required this.keyName, required this.keyType, + this.keyArg, this.onBack, this.onKeyDeleted, + this.isReadOnly = false, }); final RedisConnection connection; final int database; final String keyName; final String keyType; + + /// Wire key for GET/SET/DEL when [keyName] is only a UTF-8/hex label. + final Object? keyArg; final VoidCallback? onBack; final VoidCallback? onKeyDeleted; + final bool isReadOnly; @override material.State createState() => _RedisKeyEditorState(); @@ -34,20 +43,26 @@ class _RedisKeyEditorState extends material.State { int _ttl = -1; // String value - String? _stringValue; + RedisBulkValue? _stringValue; final _stringController = material.TextEditingController(); // Hash value - Map _hashValue = {}; + Map _hashValue = {}; // List value - List _listValue = []; + List _listValue = []; // Set value - List _setValue = []; + List _setValue = []; // Sorted set value - List<(String, double)> _zsetValue = []; + List<(RedisBulkValue, double)> _zsetValue = []; + + int _collectionTotal = 0; + int _scanCursor = 0; + bool _hasMore = false; + bool _loadingMore = false; + late String _effectiveType; // For adding new items final _newFieldController = material.TextEditingController(); @@ -56,6 +71,7 @@ class _RedisKeyEditorState extends material.State { @override void initState() { super.initState(); + _effectiveType = _normalizedType(widget.keyType); _load(); } @@ -67,6 +83,33 @@ class _RedisKeyEditorState extends material.State { super.dispose(); } + Object get _cmdKey => widget.keyArg ?? widget.keyName; + + bool get _stringIsBinary => + _effectiveType == 'string' && + _stringValue != null && + !_stringValue!.isUtf8; + + String _normalizedType(String type) { + final t = type.trim().toLowerCase(); + if (t.isEmpty) return 'unknown'; + return t; + } + + Future _resolveType() async { + final incoming = _normalizedType(widget.keyType); + if (incoming != 'unknown') return incoming; + try { + final resolved = _normalizedType( + await widget.connection.keyType(_cmdKey), + ); + if (resolved == 'none' || resolved == 'unknown') return 'unknown'; + return resolved; + } catch (_) { + return 'unknown'; + } + } + Future _load() async { if (!mounted) return; setState(() { @@ -76,25 +119,31 @@ class _RedisKeyEditorState extends material.State { }); try { await widget.connection.selectDatabase(widget.database); - _ttl = await widget.connection.ttl(widget.keyName); + _ttl = await widget.connection.ttl(_cmdKey); + _effectiveType = await _resolveType(); - switch (widget.keyType) { + switch (_effectiveType) { case 'string': - _stringValue = await widget.connection.get(widget.keyName); - _stringController.text = _stringValue ?? ''; + _stringValue = await widget.connection.get(_cmdKey); + _stringController.text = _stringValue?.text ?? ''; case 'hash': - _hashValue = await widget.connection.hgetall(widget.keyName); case 'list': - _listValue = await widget.connection.lrange(widget.keyName, 0, -1); case 'set': - _setValue = await widget.connection.smembers(widget.keyName); - _setValue.sort(); case 'zset': - _zsetValue = - await widget.connection.zrangeWithScores(widget.keyName, 0, -1); + _collectionTotal = await widget.connection.keySize( + _cmdKey, + _effectiveType, + ); + _scanCursor = 0; + _hashValue = {}; + _listValue = []; + _setValue = []; + _zsetValue = []; + _hasMore = false; + await _loadMore(reset: true); default: - _stringValue = await widget.connection.get(widget.keyName); - _stringController.text = _stringValue ?? ''; + _stringValue = null; + _stringController.text = ''; } if (!mounted) return; @@ -109,10 +158,110 @@ class _RedisKeyEditorState extends material.State { } } + int get _loadedCount => switch (_effectiveType) { + 'hash' => _hashValue.length, + 'list' => _listValue.length, + 'set' => _setValue.length, + 'zset' => _zsetValue.length, + _ => 0, + }; + + String _collectionHeading(String noun) { + if (_collectionTotal > 0) { + return '$noun ($_loadedCount / $_collectionTotal)'; + } + return '$noun ($_loadedCount)'; + } + + Widget _loadMoreTile() { + return material.Padding( + padding: const material.EdgeInsets.only(top: 12), + child: material.Center( + child: OutlineButton( + onPressed: _loadingMore ? null : () => _loadMore(), + size: ButtonSize.small, + child: _loadingMore + ? const Text('Loading...') + : Text('Load more ($_loadedCount / $_collectionTotal)'), + ), + ), + ); + } + + Future _loadMore({bool reset = false}) async { + if (_loadingMore) return; + if (!reset && mounted) setState(() => _loadingMore = true); + try { + await widget.connection.selectDatabase(widget.database); + switch (_effectiveType) { + case 'hash': + if (reset) { + _scanCursor = 0; + _hashValue = {}; + } + final (next, page) = await widget.connection.hscan( + _cmdKey, + cursor: _scanCursor, + count: redisCollectionPageSize, + ); + _hashValue.addAll(page); + _scanCursor = next; + _hasMore = next != 0; + case 'list': + if (reset) _listValue = []; + final start = _listValue.length; + final chunk = await widget.connection.lrange( + _cmdKey, + start, + start + redisCollectionPageSize - 1, + ); + _listValue.addAll(chunk); + _hasMore = _listValue.length < _collectionTotal; + case 'set': + if (reset) { + _scanCursor = 0; + _setValue = []; + } + final (next, members) = await widget.connection.sscan( + _cmdKey, + cursor: _scanCursor, + count: redisCollectionPageSize, + ); + _setValue.addAll(members); + _scanCursor = next; + _hasMore = next != 0; + case 'zset': + if (reset) _zsetValue = []; + final start = _zsetValue.length; + final chunk = await widget.connection.zrangeWithScores( + _cmdKey, + start, + start + redisCollectionPageSize - 1, + ); + _zsetValue.addAll(chunk); + _hasMore = _zsetValue.length < _collectionTotal; + } + if (!mounted) return; + setState(() => _loadingMore = false); + } catch (e) { + if (!mounted) return; + setState(() { + _loadingMore = false; + _error = 'Load failed: $e'; + }); + } + } + Future _saveString() async { + if (widget.isReadOnly) return; + if (_effectiveType != 'string' || _stringIsBinary) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.set(widget.keyName, _stringController.text); + await widget.connection.set( + _cmdKey, + _stringController.text, + keepTtl: true, + ); if (!mounted) return; setState(() => _success = 'Value saved'); _clearSuccessAfterDelay(); @@ -123,9 +272,18 @@ class _RedisKeyEditorState extends material.State { } Future _deleteKey() async { + if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisDel, + targetName: '${widget.keyName} ($_effectiveType)', + commandPreview: 'DEL ${widget.keyName}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.del(widget.keyName); + await widget.connection.del(_cmdKey); widget.onKeyDeleted?.call(); } catch (e) { if (!mounted) return; @@ -134,14 +292,15 @@ class _RedisKeyEditorState extends material.State { } Future _setTtl(int seconds) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); if (seconds > 0) { - await widget.connection.expire(widget.keyName, seconds); + await widget.connection.expire(_cmdKey, seconds); } else { - await widget.connection.persist(widget.keyName); + await widget.connection.persist(_cmdKey); } - _ttl = await widget.connection.ttl(widget.keyName); + _ttl = await widget.connection.ttl(_cmdKey); if (!mounted) return; setState(() { _success = seconds > 0 ? 'TTL set to $seconds seconds' : 'TTL removed'; @@ -155,9 +314,10 @@ class _RedisKeyEditorState extends material.State { // Hash operations Future _hashSet(String field, String value) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.hset(widget.keyName, field, value); + await widget.connection.hset(_cmdKey, field, value); await _load(); } catch (e) { if (!mounted) return; @@ -165,10 +325,19 @@ class _RedisKeyEditorState extends material.State { } } - Future _hashDel(String field) async { + Future _hashDel(RedisBulkValue field) async { + if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisHdel, + targetName: field.label, + commandPreview: 'HDEL ${widget.keyName} ${field.label}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.hdel(widget.keyName, field); + await widget.connection.hdel(_cmdKey, field); await _load(); } catch (e) { if (!mounted) return; @@ -178,9 +347,10 @@ class _RedisKeyEditorState extends material.State { // List operations Future _listPush(String value) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.rpush(widget.keyName, value); + await widget.connection.rpush(_cmdKey, value); await _load(); } catch (e) { if (!mounted) return; @@ -190,9 +360,10 @@ class _RedisKeyEditorState extends material.State { // Set operations Future _setAdd(String member) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.sadd(widget.keyName, member); + await widget.connection.sadd(_cmdKey, member); await _load(); } catch (e) { if (!mounted) return; @@ -200,10 +371,19 @@ class _RedisKeyEditorState extends material.State { } } - Future _setRemove(String member) async { + Future _setRemove(RedisBulkValue member) async { + if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisSrem, + targetName: member.label, + commandPreview: 'SREM ${widget.keyName} ${member.label}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.srem(widget.keyName, member); + await widget.connection.srem(_cmdKey, member); await _load(); } catch (e) { if (!mounted) return; @@ -213,9 +393,10 @@ class _RedisKeyEditorState extends material.State { // ZSet operations Future _zsetAdd(String member, double score) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.zadd(widget.keyName, score, member); + await widget.connection.zadd(_cmdKey, score, member); await _load(); } catch (e) { if (!mounted) return; @@ -223,10 +404,19 @@ class _RedisKeyEditorState extends material.State { } } - Future _zsetRemove(String member) async { + Future _zsetRemove(RedisBulkValue member) async { + if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisZrem, + targetName: member.label, + commandPreview: 'ZREM ${widget.keyName} ${member.label}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.zrem(widget.keyName, member); + await widget.connection.zrem(_cmdKey, member); await _load(); } catch (e) { if (!mounted) return; @@ -324,6 +514,14 @@ class _RedisKeyEditorState extends material.State { ], ), ), + if (_collectionTotal >= redisLargeCollectionWarnAt) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + child: Text( + 'Large key ($_collectionTotal members). Loading in pages of $redisCollectionPageSize.', + ).muted().small(), + ), // Header _buildHeader(cs, shadcnCs), const Divider(height: 1), @@ -331,7 +529,7 @@ class _RedisKeyEditorState extends material.State { material.Expanded( child: material.Padding( padding: const material.EdgeInsets.all(16), - child: widget.keyType == 'string' + child: _effectiveType == 'string' ? material.SingleChildScrollView( child: _buildContent(cs, shadcnCs), ) @@ -343,7 +541,7 @@ class _RedisKeyEditorState extends material.State { } Widget _buildHeader(ColorScheme cs, shadcn.ColorScheme scs) { - final typeCol = _typeColor(widget.keyType); + final typeCol = _typeColor(_effectiveType); return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), @@ -352,7 +550,7 @@ class _RedisKeyEditorState extends material.State { ), child: material.Row( children: [ - material.Icon(_typeIcon(widget.keyType), size: 18, color: typeCol), + material.Icon(_typeIcon(_effectiveType), size: 18, color: typeCol), const Gap(8), material.Container( padding: @@ -362,7 +560,7 @@ class _RedisKeyEditorState extends material.State { borderRadius: material.BorderRadius.circular(4), ), child: Text( - widget.keyType.toUpperCase(), + _effectiveType.toUpperCase(), style: material.TextStyle( fontSize: 10, fontWeight: material.FontWeight.w600, @@ -402,19 +600,19 @@ class _RedisKeyEditorState extends material.State { ), ), const Gap(8), - // TTL button - material.Tooltip( - message: 'Set TTL', - child: material.InkWell( - onTap: () => _showTtlDialog(), - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.timer_rounded, - size: 16, color: scs.mutedForeground), + if (!widget.isReadOnly) + material.Tooltip( + message: 'Set TTL', + child: material.InkWell( + onTap: () => _showTtlDialog(), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.timer_rounded, + size: 16, color: scs.mutedForeground), + ), ), ), - ), const Gap(4), // Refresh material.Tooltip( @@ -429,27 +627,28 @@ class _RedisKeyEditorState extends material.State { ), ), ), - const Gap(4), - // Delete - material.Tooltip( - message: 'Delete key', - child: material.InkWell( - onTap: _deleteKey, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.delete_rounded, - size: 16, color: context.semanticPalette.destructive), + if (!widget.isReadOnly) ...[ + const Gap(4), + material.Tooltip( + message: 'Delete key', + child: material.InkWell( + onTap: _deleteKey, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.delete_rounded, + size: 16, color: context.semanticPalette.destructive), + ), ), ), - ), + ], ], ), ); } Widget _buildContent(ColorScheme cs, shadcn.ColorScheme scs) { - switch (widget.keyType) { + switch (_effectiveType) { case 'string': return _buildStringEditor(cs, scs); case 'hash': @@ -461,27 +660,80 @@ class _RedisKeyEditorState extends material.State { case 'zset': return _buildZsetEditor(cs, scs); default: - return _buildStringEditor(cs, scs); + return _buildUnsupportedViewer(); } } + Widget _buildUnsupportedViewer() { + final unknown = _effectiveType == 'unknown'; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(unknown ? 'Type unknown' : 'Unsupported type').semiBold(), + const Gap(8), + Text( + unknown + ? 'This key is not opened as a string until TYPE succeeds. ' + 'GET and SET are disabled so the original type is not overwritten.' + : '$_effectiveType keys cannot be opened with GET/SET. ' + 'Save is disabled so the original type is not replaced with a string.', + ).muted().small(), + ], + ); + } + // ─── String ───────────────────────────────────────────────────────────── Widget _buildStringEditor(ColorScheme cs, shadcn.ColorScheme scs) { + if (_stringIsBinary) { + final bulk = _stringValue!; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text('Binary value (${bulk.bytes.length} bytes)').semiBold(), + const Gap(8), + const Text( + 'Not valid UTF-8. Save as text is disabled so the original bytes are not overwritten.', + ).muted().small(), + const Gap(12), + const Text('Hex').semiBold().small(), + const Gap(4), + material.SelectableText( + bulk.toHex(), + style: const material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + ), + const Gap(12), + const Text('Base64').semiBold().small(), + const Gap(4), + material.SelectableText( + bulk.toBase64(), + style: const material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + ), + ], + ); + } return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Row( children: [ const Text('Value').semiBold(), - const Spacer(), - PrimaryButton( - onPressed: _saveString, - size: ButtonSize.small, - leading: - const material.Icon(material.Icons.save_rounded, size: 14), - child: const Text('Save'), - ), + if (!widget.isReadOnly) ...[ + const Spacer(), + PrimaryButton( + onPressed: _saveString, + size: ButtonSize.small, + leading: + const material.Icon(material.Icons.save_rounded, size: 14), + child: const Text('Save'), + ), + ], ], ), const Gap(8), @@ -494,6 +746,7 @@ class _RedisKeyEditorState extends material.State { ), child: material.TextField( controller: _stringController, + readOnly: widget.isReadOnly, maxLines: null, style: const material.TextStyle( fontSize: 13, @@ -516,38 +769,40 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('Hash fields (${entries.length})').semiBold(), - const Gap(8), - material.Row( - children: [ - material.Expanded( - child: TextField( - controller: _newFieldController, - placeholder: const Text('Field'), + Text(_collectionHeading('Hash fields')).semiBold(), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newFieldController, + placeholder: const Text('Field'), + ), ), - ), - const Gap(8), - material.Expanded( - child: TextField( - controller: _newValueController, - placeholder: const Text('Value'), + const Gap(8), + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('Value'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final f = _newFieldController.text.trim(); - final v = _newValueController.text; - if (f.isEmpty) return; - _hashSet(f, v); - _newFieldController.clear(); - _newValueController.clear(); - }, - size: ButtonSize.small, - child: const Text('HSET'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final f = _newFieldController.text.trim(); + final v = _newValueController.text; + if (f.isEmpty) return; + _hashSet(f, v); + _newFieldController.clear(); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('HSET'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: entries.isEmpty @@ -558,15 +813,17 @@ class _RedisKeyEditorState extends material.State { itemBuilder: (context, index) { final entry = entries[index]; return _FieldRow( - field: entry.key, - value: entry.value, - onDelete: () => _hashDel(entry.key), + field: entry.key.label, + value: entry.value.label, + onDelete: + widget.isReadOnly ? null : () => _hashDel(entry.key), colorScheme: cs, shadcnCs: scs, ); }, ), ), + if (_hasMore) _loadMoreTile(), ], ); } @@ -577,30 +834,31 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('List items (${_listValue.length})').semiBold(), - const Gap(8), - // Add item - material.Row( - children: [ - material.Expanded( - child: TextField( - controller: _newValueController, - placeholder: const Text('New item'), + Text(_collectionHeading('List items')).semiBold(), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('New item'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final v = _newValueController.text; - if (v.isEmpty) return; - _listPush(v); - _newValueController.clear(); - }, - size: ButtonSize.small, - child: const Text('RPUSH'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final v = _newValueController.text; + if (v.isEmpty) return; + _listPush(v); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('RPUSH'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: _listValue.isEmpty @@ -610,12 +868,13 @@ class _RedisKeyEditorState extends material.State { separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, i) => _IndexedValueRow( index: i, - value: _listValue[i], + value: _listValue[i].label, colorScheme: cs, shadcnCs: scs, ), ), ), + if (_hasMore) _loadMoreTile(), ], ); } @@ -626,30 +885,31 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('Set members (${_setValue.length})').semiBold(), - const Gap(8), - // Add member - material.Row( - children: [ - material.Expanded( - child: TextField( - controller: _newValueController, - placeholder: const Text('New member'), + Text(_collectionHeading('Set members')).semiBold(), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('New member'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final v = _newValueController.text.trim(); - if (v.isEmpty) return; - _setAdd(v); - _newValueController.clear(); - }, - size: ButtonSize.small, - child: const Text('SADD'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final v = _newValueController.text.trim(); + if (v.isEmpty) return; + _setAdd(v); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('SADD'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: _setValue.isEmpty @@ -658,13 +918,16 @@ class _RedisKeyEditorState extends material.State { itemCount: _setValue.length, separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, index) => _MemberRow( - member: _setValue[index], - onDelete: () => _setRemove(_setValue[index]), + member: _setValue[index].label, + onDelete: widget.isReadOnly + ? null + : () => _setRemove(_setValue[index]), colorScheme: cs, shadcnCs: scs, ), ), ), + if (_hasMore) _loadMoreTile(), ], ); } @@ -675,40 +938,41 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('Sorted set (${_zsetValue.length})').semiBold(), - const Gap(8), - // Add member - material.Row( - children: [ - material.Expanded( - flex: 2, - child: TextField( - controller: _newValueController, - placeholder: const Text('Member'), + Text(_collectionHeading('Sorted set')).semiBold(), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + flex: 2, + child: TextField( + controller: _newValueController, + placeholder: const Text('Member'), + ), ), - ), - const Gap(8), - material.Expanded( - child: TextField( - controller: _newFieldController, - placeholder: const Text('Score'), + const Gap(8), + material.Expanded( + child: TextField( + controller: _newFieldController, + placeholder: const Text('Score'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final m = _newValueController.text.trim(); - final s = double.tryParse(_newFieldController.text.trim()); - if (m.isEmpty || s == null) return; - _zsetAdd(m, s); - _newValueController.clear(); - _newFieldController.clear(); - }, - size: ButtonSize.small, - child: const Text('ZADD'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final m = _newValueController.text.trim(); + final s = double.tryParse(_newFieldController.text.trim()); + if (m.isEmpty || s == null) return; + _zsetAdd(m, s); + _newValueController.clear(); + _newFieldController.clear(); + }, + size: ButtonSize.small, + child: const Text('ZADD'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: _zsetValue.isEmpty @@ -719,15 +983,17 @@ class _RedisKeyEditorState extends material.State { itemBuilder: (context, index) { final (member, score) = _zsetValue[index]; return _ScoredMemberRow( - member: member, + member: member.label, score: score, - onDelete: () => _zsetRemove(member), + onDelete: + widget.isReadOnly ? null : () => _zsetRemove(member), colorScheme: cs, shadcnCs: scs, ); }, ), ), + if (_hasMore) _loadMoreTile(), ], ); } @@ -776,6 +1042,8 @@ class _RedisKeyEditorState extends material.State { return material.Icons.scatter_plot_rounded; case 'zset': return material.Icons.sort_rounded; + case 'stream': + return material.Icons.view_stream_rounded; default: return material.Icons.help_outline_rounded; } @@ -856,14 +1124,14 @@ class _FieldRow extends StatelessWidget { const _FieldRow({ required this.field, required this.value, - required this.onDelete, + this.onDelete, required this.colorScheme, required this.shadcnCs, }); final String field; final String value; - final VoidCallback onDelete; + final VoidCallback? onDelete; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; @@ -904,15 +1172,16 @@ class _FieldRow extends StatelessWidget { ), ), const Gap(8), - material.InkWell( - onTap: onDelete, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.close_rounded, - size: 14, color: context.semanticPalette.destructive), + if (onDelete != null) + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: context.semanticPalette.destructive), + ), ), - ), ], ), ); @@ -975,13 +1244,13 @@ class _IndexedValueRow extends StatelessWidget { class _MemberRow extends StatelessWidget { const _MemberRow({ required this.member, - required this.onDelete, + this.onDelete, required this.colorScheme, required this.shadcnCs, }); final String member; - final VoidCallback onDelete; + final VoidCallback? onDelete; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; @@ -1008,15 +1277,16 @@ class _MemberRow extends StatelessWidget { ), ), const Gap(8), - material.InkWell( - onTap: onDelete, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.close_rounded, - size: 14, color: context.semanticPalette.destructive), + if (onDelete != null) + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: context.semanticPalette.destructive), + ), ), - ), ], ), ); @@ -1027,14 +1297,14 @@ class _ScoredMemberRow extends StatelessWidget { const _ScoredMemberRow({ required this.member, required this.score, - required this.onDelete, + this.onDelete, required this.colorScheme, required this.shadcnCs, }); final String member; final double score; - final VoidCallback onDelete; + final VoidCallback? onDelete; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; @@ -1078,15 +1348,16 @@ class _ScoredMemberRow extends StatelessWidget { ), ), const Gap(8), - material.InkWell( - onTap: onDelete, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.close_rounded, - size: 14, color: context.semanticPalette.destructive), + if (onDelete != null) + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: context.semanticPalette.destructive), + ), ), - ), ], ), ); diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 364402ce..b311e367 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -11,11 +14,13 @@ class RedisKeysView extends material.StatefulWidget { required this.connection, required this.database, this.onKeyTap, + this.isReadOnly = false, }); final RedisConnection connection; final int database; - final void Function(String key, String type)? onKeyTap; + final void Function(RedisBulkValue key, String type)? onKeyTap; + final bool isReadOnly; @override material.State createState() => _RedisKeysViewState(); @@ -135,6 +140,15 @@ class _RedisKeysViewState extends material.State { } Future _deleteKey(_KeyInfo keyInfo) async { + if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisDel, + targetName: '${keyInfo.name.label} (${keyInfo.type})', + commandPreview: 'DEL ${keyInfo.name.label}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.del(keyInfo.name); @@ -214,7 +228,9 @@ class _RedisKeysViewState extends material.State { palette: context.semanticPalette, onTap: () => widget.onKeyTap?.call(_keys[i].name, _keys[i].type), - onDelete: () => _deleteKey(_keys[i]), + onDelete: widget.isReadOnly + ? null + : () => _deleteKey(_keys[i]), ), ); }, @@ -304,6 +320,10 @@ class _RedisKeysViewState extends material.State { Text('db${widget.database}').muted().small(), const Gap(16), Text('$_dbSize total keys').muted().small(), + if (widget.isReadOnly) ...[ + const Gap(16), + const Text('Read-only session').muted().small(), + ], const Spacer(), Text('${_keys.length} loaded').muted().small(), if (_hasMore) ...[ @@ -324,7 +344,7 @@ class _KeyInfo { required this.type, required this.ttl, }); - final String name; + final RedisBulkValue name; final String type; final int ttl; // -1 = no expiry, -2 = key doesn't exist } @@ -338,7 +358,7 @@ class _KeyTile extends material.StatelessWidget { required this.shadcnCs, required this.palette, required this.onTap, - required this.onDelete, + this.onDelete, }); final _KeyInfo keyInfo; @@ -346,7 +366,7 @@ class _KeyTile extends material.StatelessWidget { final shadcn.ColorScheme shadcnCs; final QueryaSemanticPalette palette; final material.VoidCallback onTap; - final material.VoidCallback onDelete; + final material.VoidCallback? onDelete; static Color _typeColor(String type, QueryaSemanticPalette palette) { switch (type) { @@ -432,7 +452,7 @@ class _KeyTile extends material.StatelessWidget { const Gap(10), material.Expanded( child: material.Text( - ki.name, + ki.name.label, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( @@ -460,19 +480,20 @@ class _KeyTile extends material.StatelessWidget { ), ), const Gap(4), - material.IconButton( - onPressed: onDelete, - icon: material.Icon( - material.Icons.delete_rounded, - size: 15, - color: palette.destructive, + if (onDelete != null) + material.IconButton( + onPressed: onDelete, + icon: material.Icon( + material.Icons.delete_rounded, + size: 15, + color: palette.destructive, + ), + padding: const material.EdgeInsets.all(4), + constraints: const material.BoxConstraints( + minWidth: 28, minHeight: 28), + splashRadius: 18, + tooltip: 'Delete key', ), - padding: const material.EdgeInsets.all(4), - constraints: - const material.BoxConstraints(minWidth: 28, minHeight: 28), - splashRadius: 18, - tooltip: 'Delete key', - ), material.Icon(material.Icons.chevron_right_rounded, size: 18, color: shadcnCs.mutedForeground), ], diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index b5c98bad..fa990ff9 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -102,33 +102,50 @@ class _RedisViewState extends material.State { final conn = _connection; _connection = null; if (conn != null && _ownsConnection) { - conn.disconnect(); + unawaited(RedisService.instance.disconnect(conn)); } _ownsConnection = false; } - Future _load() async { + Future _load({bool reconnect = false}) async { _timer?.cancel(); - _disconnectCurrent(); if (!mounted) return; setState(() { _loading = true; _error = null; - _info = null; + if (reconnect) _info = null; }); try { final supplied = widget.connection; RedisConnection conn; - if (supplied != null && supplied.isConnected) { + if (supplied != null) { conn = supplied; _ownsConnection = false; + if (!conn.isConnected) { + await conn.connect(); + } } else { - conn = RedisService.instance.createConnection(widget.connectionRow); - await conn.connect(); + if (reconnect && _ownsConnection) { + final old = _connection; + _connection = null; + _ownsConnection = false; + if (old != null) { + await RedisService.instance.disconnect(old); + } + } + conn = RedisService.instance.acquire( + widget.connectionRow, + role: RedisSessionRole.stats, + ); + if (!conn.isConnected) { + await conn.connect(); + } _ownsConnection = true; } if (!mounted) { - if (_ownsConnection) conn.disconnect(); + if (_ownsConnection) { + unawaited(RedisService.instance.disconnect(conn)); + } return; } _connection = conn; @@ -144,6 +161,17 @@ class _RedisViewState extends material.State { } } + /// Header Refresh: reuse a live stats socket; do not recreate (or steal + /// Explorer's) TCP session. Retry after an error reconnects stats only. + Future _refresh() async { + final c = _connection; + if (c != null && c.isConnected) { + await _fetch(); + return; + } + await _load(reconnect: true); + } + Future _fetch() async { final c = _connection; if (c == null) return; @@ -220,7 +248,7 @@ class _RedisViewState extends material.State { color: cs.mutedForeground, fontSize: 13)), const Gap(24), OutlineButton( - onPressed: _load, + onPressed: () => unawaited(_load(reconnect: true)), leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Retry'), @@ -247,7 +275,7 @@ class _RedisViewState extends material.State { const Text('Redis INFO returned no data.').muted().small(), const Gap(24), OutlineButton( - onPressed: _load, + onPressed: () => unawaited(_load(reconnect: true)), leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Retry'), @@ -407,7 +435,7 @@ class _RedisViewState extends material.State { const Gap(8), ], OutlineButton( - onPressed: _load, + onPressed: () => unawaited(_refresh()), leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Refresh'), diff --git a/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index 14697530..fbbbefd2 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -10,7 +10,7 @@ import 'package:url_launcher/url_launcher_string.dart'; class PreferencesAboutSection extends StatelessWidget { const PreferencesAboutSection({super.key}); - static const String appVersion = '0.4.16'; + static const String appVersion = '0.4.17'; static const String githubRepoUrl = 'https://github.com/QueryaHub/Querya-Desktop'; diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 3840641d..4e46de23 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -57,6 +57,7 @@ class PreferencesDialogContentState bool _confirmDestructive = true; int? _pgTimeout; int? _mysqlTimeout; + int? _sqliteTimeout; int _maxRows = kDefaultSqlResultMaxRows; int _historyMax = kDefaultSqlHistoryMaxEntries; double _fontSize = kDefaultSqlEditorFontSize; @@ -95,6 +96,8 @@ class PreferencesDialogContentState await AppSettings.instance.getConfirmDestructiveOperations(); final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); + final sqlite = + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); @@ -105,6 +108,7 @@ class PreferencesDialogContentState _confirmDestructive = destructive; _pgTimeout = pg; _mysqlTimeout = my; + _sqliteTimeout = sqlite; _maxRows = rows; _historyMax = hist; _fontSize = font; @@ -137,6 +141,11 @@ class PreferencesDialogContentState await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v); } + Future _setSqlite(int? v) async { + setState(() => _sqliteTimeout = v); + await AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(v); + } + Future _setMaxRows(int v) async { setState(() => _maxRows = v); await AppSettings.instance.setSqlResultMaxRows(v); @@ -171,7 +180,7 @@ class PreferencesDialogContentState count++; } case PreferencesCategory.sql: - if ('font size postgresql mysql statement timeout history destructive drop truncate delete' + if ('font size postgresql mysql sqlite statement timeout history destructive drop truncate delete' .contains(q)) { count++; } @@ -620,6 +629,18 @@ class PreferencesDialogContentState ), ), + const material.SizedBox(height: 16), + PreferencesFieldRow( + label: 'SQLite timeout', + hint: + 'Application-level statement timeout for queries on SQLite connections.', + control: SqlStatementTimeoutDropdown( + value: _sqliteTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setSqlite(v)), + ), + ), + const material.SizedBox(height: 16), PreferencesFieldRow( label: 'Query history limit', diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart index 129bb956..d119d7b0 100644 --- a/lib/features/settings/sql_statement_timeout_dropdown.dart +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; -/// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL). +/// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL / SQLite). const List> kSqlStatementTimeoutMenuItems = [ QueryaDropdownItem(value: null, label: 'No limit'), QueryaDropdownItem(value: 10, label: '10 s'), diff --git a/lib/features/sqlite/sqlite_result_utils.dart b/lib/features/sqlite/sqlite_result_utils.dart index 0f616c68..7cbe4cca 100644 --- a/lib/features/sqlite/sqlite_result_utils.dart +++ b/lib/features/sqlite/sqlite_result_utils.dart @@ -1,3 +1,7 @@ +import 'dart:typed_data'; + +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + /// Serializable row batch for [convertSqliteResultRowsToStrings] in a worker isolate. class SqliteResultConvertJob { const SqliteResultConvertJob({ @@ -8,12 +12,61 @@ class SqliteResultConvertJob { } /// Converts SQLite result cell values to display strings off the UI thread. -List> convertSqliteResultRowsToStrings(SqliteResultConvertJob job) { - return job.rowValues - .map( - (row) => row - .map((value) => value == null ? 'NULL' : value.toString()) - .toList(), - ) - .toList(); +List> convertSqliteResultRowsToStrings( + SqliteResultConvertJob job) { + return [ + for (final row in job.rowValues) + [for (final value in row) sqliteResultCellToDisplayString(value)], + ]; +} + +/// BLOB as `X'deadbeef'`; other values via [Object.toString]. Null → `NULL`. +String sqliteResultCellToDisplayString( + Object? value, { + String? dataTypeName, +}) { + if (value == null) return 'NULL'; + if (value is Uint8List) { + return sqliteBlobHexLiteral(value); + } + if (value is List) { + return sqliteBlobHexLiteral(Uint8List.fromList(value)); + } + if (dataTypeName != null && + dataTypeName.isNotEmpty && + TableMutationEngine.isBinaryType(dataTypeName)) { + return sqliteBlobHexLiteralFromCell(value); + } + return value.toString(); +} + +/// SQLite blob literal `X'aabbcc'`. +String sqliteBlobHexLiteral(Uint8List bytes) { + final out = StringBuffer("X'"); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + out.write("'"); + return out.toString(); +} + +String sqliteBlobHexLiteralFromCell(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 sqliteBlobHexLiteral( + Uint8List.fromList(raw.codeUnits.map((u) => u & 0xFF).toList()), + ); + } + final clean = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), '').toLowerCase(); + return "X'$clean'"; } diff --git a/lib/features/sqlite/sqlite_sql_tx_guard.dart b/lib/features/sqlite/sqlite_sql_tx_guard.dart new file mode 100644 index 00000000..f55ed589 --- /dev/null +++ b/lib/features/sqlite/sqlite_sql_tx_guard.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +bool sqliteObjectOpensTableBrowser(SqliteObjectKind kind) { + return kind == SqliteObjectKind.table || kind == SqliteObjectKind.view; +} + +/// Confirms leaving the SQL tab (or opening a table) while a transaction is open. +Future confirmLeaveOpenSqliteTransaction( + material.BuildContext context, +) async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Open transaction'), + content: const material.Text( + 'The SQL tab has an open transaction. Leave anyway? ' + 'Uncommitted work may be lost if the session ends.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Stay'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Leave'), + ), + ], + ), + ); + return ok == true; +} + +/// Warns before opening Table Browser while the SQL editor has a `BEGIN`. +Future confirmOpenSqliteTableIfSqlTx( + material.BuildContext context, { + required ConnectionRow connection, + required SqliteObjectKind kind, +}) async { + if (!sqliteObjectOpensTableBrowser(kind)) return true; + final open = await SqliteService.instance.hasOpenSqlTransaction(connection); + if (!open) return true; + if (!context.mounted) return false; + return confirmLeaveOpenSqliteTransaction(context); +} diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 2a16d554..3d542191 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -10,12 +10,15 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/database/sqlite_sql.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; 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/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/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -24,14 +27,19 @@ class SqliteSqlWorkspace extends material.StatefulWidget { const SqliteSqlWorkspace({ super.key, required this.connectionRow, + this.transactionOpenNotifier, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Updated when transaction state changes (for tab-switch warnings). + final material.ValueNotifier? transactionOpenNotifier; + @override - material.State createState() => _SqliteSqlWorkspaceState(); + material.State createState() => + _SqliteSqlWorkspaceState(); } class _SqliteSqlWorkspaceState extends material.State { @@ -42,6 +50,9 @@ class _SqliteSqlWorkspaceState extends material.State { SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; SqliteLease? _lease; + bool? _txOpen; + + int? _queryTimeoutSeconds; int _resultMaxRows = kDefaultSqlResultMaxRows; int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; @@ -173,24 +184,33 @@ class _SqliteSqlWorkspaceState extends material.State { } Future _loadWorkspaceSettings() async { + final t = await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { + _queryTimeoutSeconds = t; _resultMaxRows = rows; _historyMaxEntries = hist; _editorFontSize = font; }); } + void _onStmtTimeoutChanged(int? v) { + setState(() => _queryTimeoutSeconds = v); + unawaited(AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(v)); + } + Future _ensureLease() async { if (_lease != null && _lease!.connection.isConnected) return; _lease?.release(); _lease = null; final lease = await SqliteService.instance.acquire( widget.connectionRow, - mode: widget.isReadOnly ? SqliteSessionMode.readOnly : SqliteSessionMode.readWrite, + mode: widget.isReadOnly + ? SqliteSessionMode.readOnly + : SqliteSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -199,6 +219,26 @@ class _SqliteSqlWorkspaceState extends material.State { _lease = lease; } + void _notifyTransactionOpen() { + widget.transactionOpenNotifier?.value = _txOpen; + } + + Future _refreshTxStatus() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) setState(() => _txOpen = null); + _notifyTransactionOpen(); + return; + } + final v = await conn.inOpenTransaction(); + if (mounted) setState(() => _txOpen = v); + _notifyTransactionOpen(); + } + + Duration? _statementTimeout() => _queryTimeoutSeconds == null + ? null + : Duration(seconds: _queryTimeoutSeconds!); + @override void dispose() { SqlEditorCommandBridge.instance @@ -247,6 +287,8 @@ class _SqliteSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -265,11 +307,14 @@ class _SqliteSqlWorkspaceState extends material.State { return; } - // Bound SELECT/WITH/VALUES at the engine before materializing rows. - // Client-side take() remains as defense for PRAGMA/EXPLAIN and author LIMIT. + // Bound SELECT/WITH-SELECT/VALUES at the engine before materializing rows. + // WITH … INSERT and assignment PRAGMA skip LIMIT (they are writes). final cap = _resultMaxRows; - final sql = injectSqlLimit(userSql, cap); - final results = await conn.execute(sql); + final sql = sqliteSqlIsReadOnlyQuery(userSql) + ? injectSqlLimit(userSql, cap) + : userSql; + final results = + await conn.executeWithTimeout(sql, timeout: _statementTimeout()); if (!mounted) return; @@ -283,19 +328,41 @@ class _SqliteSqlWorkspaceState extends material.State { final injectedLimit = sql != userSql; final rawRows = results.take(limitCount).map((row) { - return cols.map((col) => row[col]).toList(); + return cols + .map((col) => sqliteResultCellToDisplayString(row[col])) + .toList(); }).toList(); - // Adaptive convert offloads to background compute for large row sets (#522). final outRows = await convertResultRowsToStringsAdaptive(rawRows); + final target = SqlTableTargetExtractor.extract(userSql); + var pks = const []; + Map? types; + if (target != null && cols.isNotEmpty) { + try { + final meta = await conn.getTableSchema(table: target.tableName); + pks = List.from(meta.primaryKeys); + types = columnDataTypesFromSchema(meta); + } catch (_) { + pks = const []; + types = null; + } + } + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); + setState(() { session.columns = cols; session.rows = outRows; session.affectedRows = null; session.lastExecutedSql = userSql; + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); - session.stagingBuffer = cols.isNotEmpty + session.stagingBuffer = canSave ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; if (cols.isEmpty && outRows.isEmpty) { @@ -344,6 +411,8 @@ class _SqliteSqlWorkspaceState extends material.State { }); QueryaShellStatus.instance.endBusy(); } + } finally { + await _refreshTxStatus(); } } @@ -357,14 +426,23 @@ class _SqliteSqlWorkspaceState extends material.State { final target = session.lastExecutedSql != null ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) : null; - final tableName = target?.tableName ?? 'table'; + if (target == null || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + )) { + return; + } setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( dialect: SqlDialect.sqlite, - tableName: tableName, - schema: target?.schema, + tableName: target.tableName, + schema: target.schema, + primaryKeys: session.resultGridPrimaryKeys, + columnDataTypes: session.resultGridColumnDataTypes, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); @@ -386,9 +464,12 @@ class _SqliteSqlWorkspaceState extends material.State { throw StateError('Could not connect to SQLite.'); } - for (final stmt in plan.statements) { - await conn.execute(stmt.sql); - } + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); + } + }); + await _refreshTxStatus(); if (!mounted) return; final newRows = session.stagingBuffer!.effectiveRows; @@ -559,16 +640,22 @@ class _SqliteSqlWorkspaceState extends material.State { }, child: material.CallbackShortcuts( bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, control: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyT, + control: true): _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): + _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyW, + control: true): () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): + () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true, shift: true): _prevTab, + const material.SingleActivator(LogicalKeyboardKey.tab, control: true): + _nextTab, + const material.SingleActivator(LogicalKeyboardKey.tab, + control: true, shift: true): _prevTab, const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_activeSession.running) unawaited(_execute(_activeSession)); }, @@ -617,7 +704,8 @@ class _SqliteSqlWorkspaceState extends material.State { SqlQueryTabBar( sessions: _sessions, selectedIndex: _activeSessionIndex, - onSelect: (index) => setState(() => _activeSessionIndex = index), + onSelect: (index) => + setState(() => _activeSessionIndex = index), onAdd: _addNewTab, onClose: _sessions.length > 1 ? (index) => unawaited(_closeTab(index)) @@ -653,6 +741,8 @@ class _SqliteSqlWorkspaceState extends material.State { _SqliteSqlToolbar( onExecute: session.running ? null : () => _execute(session), running: session.running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, onOpenPreferences: () => showPreferencesDialog(context), onOpenHistory: widget.connectionRow.id != null && !session.running ? () { @@ -698,7 +788,15 @@ class _SqliteSqlWorkspaceState extends material.State { affectedRows: session.affectedRows, statusLine: session.statusLine, stagingBuffer: session.stagingBuffer, - onApplyChanges: widget.isReadOnly ? null : () => _applyStagedChanges(session), + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => _applyStagedChanges(session), isSaving: session.savingChanges, ), ), @@ -712,12 +810,16 @@ class _SqliteSqlToolbar extends material.StatelessWidget { const _SqliteSqlToolbar({ required this.onExecute, required this.running, + required this.queryTimeoutSeconds, + required this.onQueryTimeoutChanged, required this.onOpenPreferences, this.onOpenHistory, }); final Future Function()? onExecute; final bool running; + final int? queryTimeoutSeconds; + final void Function(int?) onQueryTimeoutChanged; final VoidCallback onOpenPreferences; final VoidCallback? onOpenHistory; @@ -727,47 +829,67 @@ class _SqliteSqlToolbar extends material.StatelessWidget { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Row( + child: material.Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, children: [ - const Text('Query').semiBold().small(), - const Spacer(), - OutlineButton( - size: ButtonSize.small, - onPressed: onOpenHistory, - leading: material.Icon( - material.Icons.history_rounded, - size: 16, - color: accent, - ), - child: const Text('History'), - ), - const Gap(8), - IconButton.ghost( - onPressed: running ? null : onOpenPreferences, - icon: material.Icon( - material.Icons.settings_rounded, - size: 20, - color: accent, - ), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + const Text('Query').semiBold().small(), + OutlineButton( + size: ButtonSize.small, + onPressed: onOpenHistory, + leading: material.Icon( + material.Icons.history_rounded, + size: 16, + color: accent, + ), + child: const Text('History'), + ), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: accent, + ), + ) + : material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + color: accent, + ), + child: const Text('Execute (F5)'), + ), + ], ), const Gap(8), - OutlineButton( - onPressed: onExecute, - leading: running - ? material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: accent, - ), - ) - : material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - color: accent, - ), - child: const Text('Execute (F5)'), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + const Text('Stmt timeout').small(), + SqlStatementTimeoutDropdown( + value: queryTimeoutSeconds, + onChanged: onQueryTimeoutChanged, + enabled: !running, + ), + IconButton.ghost( + onPressed: running ? null : onOpenPreferences, + icon: material.Icon( + material.Icons.settings_rounded, + size: 20, + color: accent, + ), + ), + ], ), ], ), diff --git a/lib/features/sqlite/sqlite_table_utils.dart b/lib/features/sqlite/sqlite_table_utils.dart new file mode 100644 index 00000000..2cbb149d --- /dev/null +++ b/lib/features/sqlite/sqlite_table_utils.dart @@ -0,0 +1,81 @@ +import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + +/// Implicit SQLite `rowid` used as the Table Browser PK when none is declared. +const kSqliteImplicitRowid = 'rowid'; + +/// Synthetic column so INSERT omits `rowid` and DML types it as INTEGER. +const sqliteImplicitRowidColumn = TableColumnMeta( + name: kSqliteImplicitRowid, + dataType: 'INTEGER', + isNullable: false, + isPrimaryKey: true, + primaryKeyPosition: 1, + omitOnInsert: true, + hasServerDefault: true, +); + +/// PK columns for Table Browser DML. +/// +/// Declared PRIMARY KEY wins. Ordinary tables with no PK use implicit `rowid`. +/// `WITHOUT ROWID` tables always declare a PK, so they never hit this fallback. +/// Views have no `rowid` and stay read-only. +List sqliteTableBrowserPrimaryKeys({ + required List declaredPrimaryKeys, + required bool isView, +}) { + if (isView) return const []; + if (declaredPrimaryKeys.isNotEmpty) { + return List.from(declaredPrimaryKeys); + } + return const [kSqliteImplicitRowid]; +} + +/// True when browse SELECT must project `rowid` (it is not in `SELECT *`). +bool sqliteBrowseNeedsRowidColumn({ + required List primaryKeys, + required bool isView, +}) { + if (isView) return false; + return primaryKeys.length == 1 && primaryKeys.first == kSqliteImplicitRowid; +} + +/// Columns for Table Browser `ORDER BY`. +/// +/// Declared PK first. Ordinary tables with no PK use implicit `rowid`. +/// Views omit `ORDER BY` (no PK, no `rowid`). +List sqliteBrowseOrderColumns({ + required List primaryKeys, + required bool isView, +}) { + if (primaryKeys.isNotEmpty) return List.from(primaryKeys); + if (!isView) return const [kSqliteImplicitRowid]; + return const []; +} + +/// Browse SELECT for Table Browser. PK / `rowid` keep LIMIT/OFFSET stable. +/// +/// Implicit-`rowid` tables project `"rowid", *` so DML WHERE can address the row. +String sqliteBrowseDataSql({ + required String qualifiedFrom, + required List primaryKeys, + required bool isView, + required int limit, + required int offset, +}) { + final orderCols = sqliteBrowseOrderColumns( + primaryKeys: primaryKeys, + isView: isView, + ); + final pks = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: primaryKeys, + isView: isView, + ); + final select = sqliteBrowseNeedsRowidColumn(primaryKeys: pks, isView: isView) + ? '${SqliteConnection.quoteIdentifier(kSqliteImplicitRowid)}, *' + : '*'; + final order = orderCols.isEmpty + ? '' + : ' ORDER BY ${orderCols.map(SqliteConnection.quoteIdentifier).join(', ')}'; + return 'SELECT $select FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; +} diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 4310615b..32c82b39 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -5,9 +5,12 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/sqlite_connection.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/database/table_schema_meta.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -21,6 +24,7 @@ class SqliteTableView extends material.StatefulWidget { this.isView = false, this.limit = _defaultLimit, this.onNavigateHome, + this.isReadOnly = false, }); final ConnectionRow connectionRow; @@ -29,6 +33,9 @@ class SqliteTableView extends material.StatefulWidget { final int limit; final VoidCallback? onNavigateHome; + /// Title-bar session lock. Combined with connection-form Read only (`useSSL`). + final bool isReadOnly; + @override material.State createState() => _SqliteTableViewState(); } @@ -49,15 +56,21 @@ class _SqliteTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; List _primaryKeys = []; Map _columnDataTypes = {}; + Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; bool get _isDirty => _stagingBuffer?.isDirty ?? false; + bool get _readOnly => widget.isReadOnly || widget.connectionRow.useSSL; + bool get _editingEnabled => tableViewEditingEnabled( isView: widget.isView, customSqlActive: false, hasPrimaryKey: _primaryKeys.isNotEmpty, + readOnly: _readOnly, + schemaError: _schemaError, ); String _qualifiedFrom() { @@ -65,7 +78,13 @@ class _SqliteTableViewState extends material.State { } String _browseDataSql() { - return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; + return sqliteBrowseDataSql( + qualifiedFrom: _qualifiedFrom(), + primaryKeys: _primaryKeys, + isView: widget.isView, + limit: widget.limit, + offset: _offset, + ); } @override @@ -83,13 +102,16 @@ class _SqliteTableViewState extends material.State { _resetStaging(); _disconnectCurrent(); _connectAndLoad(); + } else if (oldWidget.isReadOnly != widget.isReadOnly || + oldWidget.connectionRow.useSSL != widget.connectionRow.useSSL) { + _syncStagingToReadOnly(); } } @override void dispose() { _resetStaging(); - _disconnectCurrent(); + _disconnectCurrent(interruptIfBusy: true); super.dispose(); } @@ -98,11 +120,40 @@ class _SqliteTableViewState extends material.State { _stagingBuffer = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } - void _disconnectCurrent() { + void _syncStagingToReadOnly() { + if (_readOnly) { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + } else if (_columnNames.isNotEmpty) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: _editingEnabled, + ); + } + if (mounted) setState(() {}); + } + + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + SqliteService.instance.interrupt( + widget.connectionRow, + mode: SqliteSessionMode.readOnly, + ); + } + if (interruptIfBusy && _isSaving) { + SqliteService.instance.interrupt( + widget.connectionRow, + mode: SqliteSessionMode.tableWrite, + ); + } _lease?.release(); _lease = null; } @@ -123,14 +174,14 @@ class _SqliteTableViewState extends material.State { try { final lease = await SqliteService.instance.acquire( widget.connectionRow, - mode: SqliteSessionMode.readWrite, + mode: SqliteSessionMode.readOnly, ); if (!mounted) { lease.release(); return; } _lease = lease; - await _fetch(refreshCount: true); + await _fetch(); } catch (e) { if (!mounted) return; setState(() { @@ -140,6 +191,23 @@ class _SqliteTableViewState extends material.State { } } + Future _withTableWrite( + Future Function(SqliteConnection conn) fn, + ) async { + if (_readOnly) { + throw StateError('SQLite connection is read-only'); + } + final lease = await SqliteService.instance.acquire( + widget.connectionRow, + mode: SqliteSessionMode.tableWrite, + ); + try { + return await fn(lease.connection); + } finally { + lease.release(); + } + } + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -152,17 +220,38 @@ class _SqliteTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema(table: widget.tableName); - _primaryKeys = List.from(schema.primaryKeys); + final loaded = await loadTableViewSchema( + () => conn.getTableSchema(table: widget.tableName), + ); + final schema = loaded.schema; + if (schema != null) { + _primaryKeys = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: widget.isView, + ); _columnDataTypes = columnDataTypesFromSchema(schema); - } catch (_) { + _columnMeta = columnMetaFromSchema(schema); + if (sqliteBrowseNeedsRowidColumn( + primaryKeys: _primaryKeys, + isView: widget.isView, + ) && + !_columnMeta.containsKey(kSqliteImplicitRowid)) { + _columnDataTypes[kSqliteImplicitRowid] = + sqliteImplicitRowidColumn.dataType; + _columnMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; + } + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -176,7 +265,7 @@ class _SqliteTableViewState extends material.State { ); } - Future _fetch({bool refreshCount = false}) async { + Future _fetch() async { final conn = _connection; if (conn == null || !conn.isConnected) { if (mounted && _loading) { @@ -192,26 +281,18 @@ class _SqliteTableViewState extends material.State { _error = null; }); try { - if (refreshCount) { - try { - final countRs = - await conn.execute('SELECT COUNT(*) FROM ${_qualifiedFrom()}'); - if (countRs.isNotEmpty) { - _totalRowCount = countRs.first.values.first as int?; - } - } catch (_) { - _totalRowCount = null; - } - } + await _ensureSchema(conn); + if (!mounted) return; + + // Skip COUNT(*) — it blocks the FFI isolate on large files. Next is + // enabled when the current page is full (_canGoNext). + _totalRowCount = null; final browseSql = _browseDataSql(); final rs = await conn.execute(browseSql); if (!mounted) return; - await _ensureSchema(conn); - if (!mounted) return; - final cols = []; if (rs.isNotEmpty) { cols.addAll(rs.first.keys); @@ -221,8 +302,10 @@ class _SqliteTableViewState extends material.State { final outRows = rs.map((row) { return cols.map((col) { - final val = row[col]; - return val == null ? 'NULL' : val.toString(); + return sqliteResultCellToDisplayString( + row[col], + dataTypeName: _columnDataTypes[col], + ); }).toList(); }).toList(); @@ -274,7 +357,9 @@ class _SqliteTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; - await _fetch(refreshCount: true); + _schemaLoaded = false; + _schemaError = null; + await _fetch(); } Future _onNavigateHome() async { @@ -286,6 +371,7 @@ class _SqliteTableViewState extends material.State { } Future _applyStagedChanges() async { + if (_readOnly) return; final buffer = _stagingBuffer; if (buffer == null || !buffer.isDirty || _isSaving) return; setState(() => _isSaving = true); @@ -296,23 +382,18 @@ class _SqliteTableViewState extends material.State { tableName: widget.tableName, primaryKeys: _primaryKeys, columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { - final conn = _connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to SQLite.'); - } - await conn.execute('BEGIN TRANSACTION'); - try { - for (final stmt in plan.statements) { - await conn.execute(stmt.sql); + await _withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to SQLite.'); } - await conn.execute('COMMIT'); - } catch (e) { - try { - await conn.execute('ROLLBACK'); - } catch (_) {} - rethrow; - } + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); + } + }); + }); }, ); if (!mounted) return; @@ -431,6 +512,8 @@ class _SqliteTableViewState extends material.State { customSqlActive: false, hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, + readOnly: _readOnly, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (pag.isEmpty) return reason; diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 45307009..19aefd46 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/storage/local_db.dart' show ConnectionRow; import 'package:querya_desktop/features/connections/connections_panel.dart' show SqliteObjectKind; import 'package:querya_desktop/features/sqlite/sqlite_overview_tab.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -22,10 +23,7 @@ class SqliteWorkspaceHome extends material.StatefulWidget { final bool isReadOnly; /// Remembers the last visited table/view for 1-click return. - final ({ - String name, - SqliteObjectKind kind - })? lastSelectedSqliteObject; + final ({String name, SqliteObjectKind kind})? lastSelectedSqliteObject; final VoidCallback? onRestoreLastSelectedObject; @override @@ -35,11 +33,13 @@ class SqliteWorkspaceHome extends material.StatefulWidget { class _SqliteWorkspaceHomeState extends material.State { int _tab = 0; + late final material.ValueNotifier _sqlTxNotifier; int _lastAppliedSqlTabToken = 0; @override void initState() { super.initState(); + _sqlTxNotifier = material.ValueNotifier(null); _lastAppliedSqlTabToken = widget.sqlTabRequestToken; } @@ -60,8 +60,18 @@ class _SqliteWorkspaceHomeState extends material.State { } } - void _selectTab(int i) { + @override + void dispose() { + _sqlTxNotifier.dispose(); + super.dispose(); + } + + Future _selectTab(int i) async { if (i == _tab) return; + if (_tab == 1 && i == 0 && _sqlTxNotifier.value == true) { + final ok = await confirmLeaveOpenSqliteTransaction(context); + if (!ok) return; + } setState(() => _tab = i); } @@ -98,7 +108,8 @@ class _SqliteWorkspaceHomeState extends material.State { material.Icons.table_chart_outlined, size: 14, ), - child: Text('Return to ${widget.lastSelectedSqliteObject!.name}'), + child: Text( + 'Return to ${widget.lastSelectedSqliteObject!.name}'), ), ], const Spacer(), @@ -122,6 +133,7 @@ class _SqliteWorkspaceHomeState extends material.State { SqliteSqlWorkspace( key: ValueKey('sqlite_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + transactionOpenNotifier: _sqlTxNotifier, isReadOnly: widget.isReadOnly, ), ], diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 758a03db..be638f41 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -53,8 +53,8 @@ Future showUnsavedUpdateRestartDialog(material.BuildContext context) { const Text('Unsaved changes').semiBold().large(), const Gap(8), const Text( - 'You have unsaved SQL or staged table changes. ' - 'Restarting to install the update will discard them.', + 'You have unsaved SQL, staged table edits, or document ' + 'changes. Restarting to install the update will discard them.', ).muted().small(), const Gap(20), material.Align( diff --git a/lib/features/workspace/data_grid_staging_buffer.dart b/lib/features/workspace/data_grid_staging_buffer.dart index 23399db6..f9cccb8a 100644 --- a/lib/features/workspace/data_grid_staging_buffer.dart +++ b/lib/features/workspace/data_grid_staging_buffer.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.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/unsaved_work_registry.dart'; /// Status of a row within the staging buffer. @@ -297,6 +298,7 @@ class DataGridStagingBuffer extends ChangeNotifier { String? schema, List primaryKeys = const [], Map? columnDataTypes, + Map? columnMeta, }) { return TableMutationEngine.generatePlan( dialect: dialect, @@ -309,6 +311,7 @@ class DataGridStagingBuffer extends ChangeNotifier { insertedRows: _insertedRows, deletedRowIndices: _deletedRowIndices, columnDataTypes: columnDataTypes, + columnMeta: columnMeta, ); } diff --git a/lib/features/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index af5f74e5..29a6cb2d 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -23,6 +23,50 @@ Future showDestructiveQueryDialog({ ); } +/// Same dialog as SQL DROP/DELETE, with a command preview (Mongo / Redis). +/// +/// Returns `true` only when the user checks the acknowledgment box and +/// confirms. Cancel / Escape returns `false`. +Future confirmDestructiveAction({ + required material.BuildContext context, + required DestructiveSqlType type, + required String targetName, + required String commandPreview, + String? connectionName, +}) async { + final confirmed = await showDestructiveQueryDialog( + context: context, + result: DestructiveSqlInspectionResult( + operations: [ + DestructiveSqlOperation( + type: type, + targetName: targetName, + rawStatement: commandPreview, + ), + ], + ), + sql: commandPreview, + connectionName: connectionName, + ); + return confirmed == true; +} + +/// Mongo explorer drop/delete. Same dialog as SQL. +Future confirmDestructiveMongoAction({ + required material.BuildContext context, + required DestructiveSqlType type, + required String targetName, + required String commandPreview, + String? connectionName, +}) => + confirmDestructiveAction( + context: context, + type: type, + targetName: targetName, + commandPreview: commandPreview, + connectionName: connectionName, + ); + class _DestructiveQueryDialog extends material.StatefulWidget { const _DestructiveQueryDialog({ required this.result, @@ -39,7 +83,8 @@ class _DestructiveQueryDialog extends material.StatefulWidget { _DestructiveQueryDialogState(); } -class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialog> { +class _DestructiveQueryDialogState + extends material.State<_DestructiveQueryDialog> { bool _acknowledged = false; bool _copied = false; @@ -67,14 +112,14 @@ class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialo maxHeight: 580, ), child: material.FocusTraversalGroup( - child: material.SizedBox( - height: 540, - child: material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.SizedBox( + height: 540, + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ // Header material.Row( children: [ @@ -245,12 +290,14 @@ class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialo children: [ material.Checkbox( value: _acknowledged, - onChanged: (v) => setState(() => _acknowledged = v ?? false), + onChanged: (v) => + setState(() => _acknowledged = v ?? false), ), const Gap(8), material.Expanded( child: material.GestureDetector( - onTap: () => setState(() => _acknowledged = !_acknowledged), + onTap: () => + setState(() => _acknowledged = !_acknowledged), child: const Text( 'I understand that this query cannot be undone and may result in permanent data loss.', ).small(), @@ -270,7 +317,8 @@ class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialo crossAxisAlignment: material.WrapCrossAlignment.center, children: [ GhostButton( - onPressed: () => material.Navigator.of(context).pop(false), + onPressed: () => + material.Navigator.of(context).pop(false), child: const Text('Cancel'), ), DestructiveButton( diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index ec0d1132..9d3b6199 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; @@ -7,6 +8,11 @@ import 'package:querya_desktop/features/workspace/xml_html_formatter.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Opens a rich modal inspector for viewing and editing large text, JSON, XML, or BLOB values. +/// +/// **Apply** and **Ctrl/Cmd+Enter** return the edited string for local staging +/// (SQL grid). When [onSaveToDatabase] is set (Mongo field inspector), those +/// same actions persist through the callback — they are not a local-only +/// dismiss. **Cancel** still closes without writing. Future showGridCellInspectorDialog({ required material.BuildContext context, required String columnName, @@ -201,6 +207,10 @@ class _GridCellInspectorDialogState } void _apply() { + if (widget.onSaveToDatabase != null) { + unawaited(_saveToDatabase()); + return; + } final result = _isNull ? 'NULL' : _controller.text; material.Navigator.of(context).pop(result); } diff --git a/lib/features/workspace/grid_data_type_validator.dart b/lib/features/workspace/grid_data_type_validator.dart index aa3a0d35..0b1c1172 100644 --- a/lib/features/workspace/grid_data_type_validator.dart +++ b/lib/features/workspace/grid_data_type_validator.dart @@ -24,6 +24,25 @@ 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(); + if (lower != 'true' && + lower != 'false' && + lower != '1' && + lower != '0' && + lower != 't' && + lower != 'f') { + return 'Expected boolean (true/false/1/0)'; + } + return null; + } + // Integer types if (type.contains('int') || type == 'serial' || type == 'bigserial') { if (!_intRegex.hasMatch(value.trim())) { @@ -44,20 +63,6 @@ abstract final class GridDataTypeValidator { return null; } - // Boolean types - if (type == 'bool' || type == 'boolean') { - final lower = value.toLowerCase().trim(); - if (lower != 'true' && - lower != 'false' && - lower != '1' && - lower != '0' && - lower != 't' && - lower != 'f') { - return 'Expected boolean (true/false/1/0)'; - } - return null; - } - // UUID if (type == 'uuid') { if (!_uuidRegex.hasMatch(value.trim())) { @@ -99,11 +104,12 @@ abstract final class GridDataTypeValidator { return null; } - // Binary / BLOB / Bytea + // Binary / BLOB / Bytea / BIT if (type.contains('blob') || type.contains('bytea') || type.contains('binary') || type.contains('varbinary') || + type.startsWith('bit') || type == 'raw' || type == 'image') { var hex = value.trim(); diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index ef8f15f0..53c9f97b 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -39,6 +39,12 @@ class SqlQueryTabSession { bool savingChanges = false; bool isModified = false; + /// PK columns for SQL-grid Save, empty when Save is disabled. + List resultGridPrimaryKeys = const []; + + /// Column types from [getTableSchema] for DML literals, if resolved. + Map? resultGridColumnDataTypes; + void formatSql() { final next = formatSqlScript(controller.text); controller.value = material.TextEditingValue( diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 87c2daa8..5fbee046 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -5,14 +5,47 @@ import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart' import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +/// Outcome of [loadTableViewSchema] (success vs swallowed getTableSchema error). +class TableViewSchemaLoad { + const TableViewSchemaLoad._({this.schema, this.error}); + + const TableViewSchemaLoad.ok(TableSchemaMeta schema) : this._(schema: schema); + + const TableViewSchemaLoad.failed(Object error) : this._(error: error); + + final TableSchemaMeta? schema; + final Object? error; + + bool get isOk => schema != null; +} + +/// Runs [fetch] and captures a failure instead of treating it as “no PK”. +Future loadTableViewSchema( + Future Function() fetch, +) async { + try { + return TableViewSchemaLoad.ok(await fetch()); + } catch (e) { + return TableViewSchemaLoad.failed(e); + } +} + +/// Status copy when [loadTableViewSchema] failed. Editing stays off. +String tableViewSchemaUnavailableReason(Object error) { + return 'Cannot edit: schema unavailable. $error. Refresh to retry.'; +} + /// Whether Table Browser should attach a [DataGridStagingBuffer] for this page. bool tableViewEditingEnabled({ required bool isView, bool isMaterializedView = false, required bool customSqlActive, required bool hasPrimaryKey, + bool readOnly = false, + Object? schemaError, }) { - if (isView || isMaterializedView || customSqlActive) return false; + if (readOnly || isView || isMaterializedView || customSqlActive) return false; + if (schemaError != null) return false; return hasPrimaryKey; } @@ -23,9 +56,15 @@ String? tableViewEditDisabledReason({ required bool customSqlActive, required bool hasPrimaryKey, required bool schemaLoaded, + bool readOnly = false, + Object? schemaError, }) { + if (readOnly) return 'Read-only session'; if (isView || isMaterializedView) return 'Views are read-only'; if (customSqlActive) return 'Custom SQL results are read-only'; + if (schemaError != null) { + return tableViewSchemaUnavailableReason(schemaError); + } if (schemaLoaded && !hasPrimaryKey) { return 'Cannot edit: no primary key detected'; } @@ -40,6 +79,11 @@ Map columnDataTypesFromSchema(TableSchemaMeta schema) { }; } +/// Column name → schema flags used by INSERT (generated / defaults). +Map columnMetaFromSchema(TableSchemaMeta schema) { + return {for (final c in schema.columns) c.name: c}; +} + /// Disposes [previous] and returns a new buffer when [enabled]. DataGridStagingBuffer? replaceTableViewStagingBuffer({ DataGridStagingBuffer? previous, @@ -119,6 +163,15 @@ Future showDiscardTableEditsDialog({ ); } +/// Throws if a DML statement matched no rows (stale PK / concurrent delete). +void expectDmlMatchedRows(int affectedRows) { + if (affectedRows >= 1) return; + throw StateError( + 'Save failed: a statement matched 0 rows. ' + 'The row may have been changed or deleted. Refresh and try again.', + ); +} + Future showTableViewSaveFailedDialog({ required material.BuildContext context, required Object error, @@ -178,6 +231,10 @@ class TableViewApplyOutcome { } /// Preview + execute a staging-buffer mutation plan for Table Browser. +/// +/// [execute] must throw if any statement matched 0 rows (see +/// [expectDmlMatchedRows]) so this returns [TableViewApplyOutcome.failed] +/// and the caller keeps the staging buffer. Future applyTableViewStagedChanges({ required material.BuildContext context, required DataGridStagingBuffer buffer, @@ -186,6 +243,7 @@ Future applyTableViewStagedChanges({ String? schema, required List primaryKeys, Map? columnDataTypes, + Map? columnMeta, required Future Function(TableMutationPlan plan) execute, }) async { if (!buffer.isDirty) return const TableViewApplyOutcome.noop(); @@ -201,6 +259,7 @@ Future applyTableViewStagedChanges({ schema: schema, primaryKeys: primaryKeys, columnDataTypes: columnDataTypes, + columnMeta: columnMeta, ); if (plan.isEmpty) return const TableViewApplyOutcome.noop(); diff --git a/pubspec.yaml b/pubspec.yaml index e476ee16..060abbdd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.17+2 +version: 0.4.17+1 diff --git a/test/core/database/mongo_service_test.dart b/test/core/database/mongo_service_test.dart index 71bcd4e4..84feb5ca 100644 --- a/test/core/database/mongo_service_test.dart +++ b/test/core/database/mongo_service_test.dart @@ -3,6 +3,31 @@ import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; void main() { + group('expectMongoDocumentMatched', () { + test('allows 1+ matched documents', () { + expectMongoDocumentMatched(1, operation: 'updateOne'); + expectMongoDocumentMatched(2, operation: 'replaceOne'); + }); + + test('throws on 0 matches so Save is a failure', () { + expect( + () => expectMongoDocumentMatched(0, operation: 'updateOne'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('matched 0 documents'), + ), + ), + ); + }); + + test('nModified 0 is not a failure when the filter matched', () { + // Identical $set: nMatched=1, nModified=0 — still a successful save. + expectMongoDocumentMatched(1, operation: 'updateOne'); + }); + }); + group('MongoService.createConnection', () { test('creates MongoConnection from ConnectionRow', () { const row = ConnectionRow( diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index 614ab618..d986a864 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -182,6 +182,42 @@ void main() { ); }); + test('buildUriForDatabase still contains auth after scrubCredentials', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'atlas.example.com', + username: 'app', + password: 's3cret', + database: 'prod', + authSource: 'admin', + ); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + + final uri = conn.buildUriForDatabase('analytics'); + expect(uri, contains('app:${Uri.encodeComponent('s3cret')}@')); + expect(uri, contains('/analytics')); + expect(uri, contains('authSource=admin')); + }); + + test('scrubCredentials does not persist session URI onto connectionString getter', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + username: 'u', + password: 'p', + connectionString: 'mongodb://u:p@localhost/db', + ); + conn.scrubCredentials(); + expect(conn.connectionString, isNull); + expect(conn.buildConnectionUri(), 'mongodb://u:p@localhost/db'); + }); + test('empty connectionString falls back to building URI', () { final conn = MongoConnection( id: 1, diff --git a/test/core/database/mongodb_uri_replacement_test.dart b/test/core/database/mongodb_uri_replacement_test.dart index 2ac7531c..6e5fd33b 100644 --- a/test/core/database/mongodb_uri_replacement_test.dart +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -199,5 +199,22 @@ void main() { expect(uri, contains('mongodb://')); expect(Uri.parse(uri).path, '/'); }); + + test('password remains in URI after scrubCredentials', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + database: 'mydb', + ); + conn.scrubCredentials(); + expect(conn.password, isNull); + final uri = conn.buildUriForDatabase('otherdb'); + expect(uri, contains('root:root')); + expect(uri, contains('/otherdb')); + expect(uri, contains('authSource=mydb')); + }); }); } diff --git a/test/core/database/mysql_connection_pool_test.dart b/test/core/database/mysql_connection_pool_test.dart index 0448b593..cdafe6a9 100644 --- a/test/core/database/mysql_connection_pool_test.dart +++ b/test/core/database/mysql_connection_pool_test.dart @@ -24,6 +24,8 @@ class FakeMysqlConnection extends MysqlConnection { int disconnectCount = 0; int forceCloseCount = 0; int setReadOnlyCount = 0; + bool? lastReadOnly; + bool? openTransaction; @override bool get isConnected => _connected; @@ -49,7 +51,11 @@ class FakeMysqlConnection extends MysqlConnection { @override Future setSessionReadOnly(bool readOnly) async { setReadOnlyCount++; + lastReadOnly = readOnly; } + + @override + Future inOpenTransaction() async => openTransaction; } void main() { @@ -57,7 +63,8 @@ void main() { test('acquire increments refs and connects if needed', () async { final fake = FakeMysqlConnection(); final pool = MysqlConnectionPool( - createAndConnect: (row, {required database, required mode}) async => fake, + createAndConnect: (row, {required database, required mode}) async => + fake, ); final lease = await pool.acquire(_row(id: 1), database: 'testdb'); @@ -95,5 +102,102 @@ void main() { throwsA(isA()), ); }); + + test('tableWrite is a separate key from SQL readWrite', () async { + final created = []; + final pool = MysqlConnectionPool( + createAndConnect: (row, {required database, required mode}) async { + final c = FakeMysqlConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + created.add(c); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.tableWrite); + final browse = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readOnly); + expect(identical(sql.connection, grid.connection), isFalse); + expect(identical(sql.connection, browse.connection), isFalse); + expect(created.length, 3); + expect( + pool.keyFor(1, 'app', MysqlSessionMode.tableWrite), + '1::app::tableWrite', + ); + expect((browse.connection as FakeMysqlConnection).lastReadOnly, isTrue); + expect((grid.connection as FakeMysqlConnection).lastReadOnly, isFalse); + sql.release(); + grid.release(); + browse.release(); + }); + + test('interrupt of SQL readWrite does not kill tableWrite or readOnly', + () async { + final pool = MysqlConnectionPool( + createAndConnect: (row, {required database, required mode}) async { + final c = FakeMysqlConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.tableWrite); + final browse = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readOnly); + final sqlFake = sql.connection as FakeMysqlConnection; + final gridFake = grid.connection as FakeMysqlConnection; + final browseFake = browse.connection as FakeMysqlConnection; + + pool.interrupt(r, database: 'app', mode: MysqlSessionMode.readWrite); + expect(sqlFake.forceCloseCount, 1); + expect(gridFake.forceCloseCount, 0); + expect(browseFake.forceCloseCount, 0); + + pool.interruptAllModes(r, database: 'app'); + expect(gridFake.forceCloseCount, 1); + expect(browseFake.forceCloseCount, 1); + sql.release(); + grid.release(); + browse.release(); + }); + + test('hasOpenSqlTransaction reads the SQL slot only', () async { + final pool = MysqlConnectionPool( + createAndConnect: (row, {required database, required mode}) async { + final c = FakeMysqlConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + expect( + await pool.hasOpenSqlTransaction(r, database: 'app'), + isFalse, + ); + + final sql = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readWrite); + (sql.connection as FakeMysqlConnection).openTransaction = true; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isTrue); + expect( + await pool.hasOpenSqlTransaction(r, database: 'other'), + isFalse, + ); + + final grid = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.tableWrite); + (grid.connection as FakeMysqlConnection).openTransaction = true; + (sql.connection as FakeMysqlConnection).openTransaction = false; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isFalse); + sql.release(); + grid.release(); + }); }); } diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index d3a4f6bf..9c895712 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; void main() { group('MysqlConnection SSL URI parsing', () { @@ -21,6 +22,110 @@ void main() { isTrue, ); }); + + test('ssl-mode=prefer enables TLS even when fallback is off', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=prefer', + fallbackSsl: false, + ), + isTrue, + ); + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://localhost/db?ssl-mode=prefer', + fallbackSsl: false, + ), + MysqlSslMode.encrypt, + ); + }); + + test('ssl-mode=require enables TLS', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=require', + fallbackSsl: false, + ), + isTrue, + ); + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://localhost/db?ssl-mode=require', + ), + MysqlSslMode.encrypt, + ); + }); + + test('ssl-mode=disable stays off without cert params', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=disable', + fallbackSsl: true, + ), + isFalse, + ); + }); + + test('ssl-mode=verify_identity verifies CA and hostname', () { + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://db.example.com/db?ssl-mode=verify_identity&sslrootcert=%2Fca.pem', + ), + MysqlSslMode.verifyIdentity, + ); + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://db.example.com/db?ssl-mode=verify-full&sslrootcert=%2Fca.pem', + ), + MysqlSslMode.verifyIdentity, + ); + }); + + test('ssl-mode=verify_ca requires sslrootcert', () { + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://localhost/db?ssl-mode=verify_ca&sslrootcert=%2Fca.pem', + ), + MysqlSslMode.verifyCa, + ); + expect( + () => validateMysqlSslMode( + MysqlSslMode.verifyCa, + const SslCertificatePaths(), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('sslrootcert'), + ), + ), + ); + expect( + () => validateMysqlSslMode( + MysqlSslMode.verifyIdentity, + const SslCertificatePaths(), + ), + throwsA(isA()), + ); + expect( + () => validateMysqlSslMode( + MysqlSslMode.verifyCa, + const SslCertificatePaths(rootCert: '/ca.pem'), + ), + returnsNormally, + ); + }); + + test('connect fails closed on verify_ca without sslrootcert', () async { + final conn = MysqlConnection( + id: 0, + name: 't', + host: 'localhost', + connectionString: 'mysql://u:p@example.com/db?ssl-mode=verify_ca', + ); + await expectLater(conn.connect(), throwsA(isA())); + }); }); group('replaceDatabaseInMysqlConnectionString', () { @@ -52,6 +157,21 @@ void main() { }); }); + group('MysqlConnection.sessionTransactionAccessModeSql', () { + test( + 'documents SET SESSION TRANSACTION (not default_transaction_read_only)', + () { + expect( + MysqlConnection.sessionTransactionAccessModeSql(true), + 'SET SESSION TRANSACTION READ ONLY', + ); + expect( + MysqlConnection.sessionTransactionAccessModeSql(false), + 'SET SESSION TRANSACTION READ WRITE', + ); + }); + }); + group('MysqlConnection.quoteIdentifier', () { test('escapes backticks', () { expect( diff --git a/test/core/database/mysql_grid_types_test.dart b/test/core/database/mysql_grid_types_test.dart new file mode 100644 index 00000000..6f21c737 --- /dev/null +++ b/test/core/database/mysql_grid_types_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mysql_client/mysql_client.dart'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +void main() { + group('mysqlColumnSchemaType', () { + test('prefers COLUMN_TYPE so BOOLEAN is tinyint(1)', () { + expect( + mysqlColumnSchemaType(dataType: 'tinyint', columnType: 'tinyint(1)'), + 'tinyint(1)', + ); + expect( + mysqlColumnSchemaType(dataType: 'bit', columnType: 'bit(8)'), + 'bit(8)', + ); + expect( + mysqlColumnSchemaType(dataType: 'json', columnType: 'json'), + 'json', + ); + expect( + mysqlColumnSchemaType(dataType: 'blob', columnType: ''), + 'blob', + ); + }); + }); + + group('all_mysql_types display + persist', () { + test('BOOLEAN tinyint(1) displays true/false and persists TRUE/FALSE', () { + expect( + mysqlResultCellToDisplayString('1', schemaDataType: 'tinyint(1)'), + 'true', + ); + expect( + mysqlResultCellToDisplayString('0', schemaDataType: 'tinyint(1)'), + 'false', + ); + expect( + TableMutationEngine.formatLiteral( + 'true', + SqlDialect.mysql, + dataTypeName: 'tinyint(1)', + ), + 'TRUE', + ); + expect( + TableMutationEngine.formatLiteral( + '0', + SqlDialect.mysql, + dataTypeName: 'tinyint(1)', + ), + 'FALSE', + ); + expect(TableMutationEngine.isBoolType('tinyint'), isFalse); + expect(TableMutationEngine.isBoolType('tinyint(1)'), isTrue); + }); + + test('BIT(8) latin1 byte displays as hex and persists X\'..\'', () { + final bitCol = ResultSetColumn( + name: 'col_bit', + type: MySQLColumnType.bitType, + length: 8, + charset: 63, + ); + final raw = latin1.decode(const [0xaa]); + expect( + mysqlResultCellToDisplayString(raw, column: bitCol), + '0xaa', + ); + expect( + TableMutationEngine.formatLiteral( + '0xaa', + SqlDialect.mysql, + dataTypeName: 'bit(8)', + ), + "X'aa'", + ); + }); + + test('BLOB / BINARY display as hex and persist the same', () { + const blob = 'Binary BLOB payload'; + expect( + mysqlResultCellToDisplayString(blob, schemaDataType: 'blob'), + binaryCellToHexDisplay(blob), + ); + expect( + binaryCellToHexDisplay(Uint8List.fromList([0xde, 0xad, 0xbe, 0xef])), + '0xdeadbeef', + ); + expect( + TableMutationEngine.formatLiteral( + '0xdeadbeef', + SqlDialect.mysql, + dataTypeName: 'binary(16)', + ), + "X'deadbeef'", + ); + expect( + TableMutationEngine.formatLiteral( + r'\xcafebabe', + SqlDialect.mysql, + dataTypeName: 'varbinary(64)', + ), + "X'cafebabe'", + ); + }); + + test('JSON stays quoted text', () { + const json = '{"name":"Querya MySQL","version":"8.4"}'; + expect( + mysqlResultCellToDisplayString(json, schemaDataType: 'json'), + json, + ); + expect( + TableMutationEngine.formatLiteral( + json, + SqlDialect.mysql, + dataTypeName: 'json', + ), + "'$json'", + ); + }); + + test('TINYINT(1) driver column displays as boolean without schema', () { + final col = ResultSetColumn( + name: 'col_boolean', + type: MySQLColumnType.tinyType, + length: 1, + ); + expect(mysqlResultCellToDisplayString('1', column: col), 'true'); + expect(mysqlResultCellToDisplayString('0', column: col), 'false'); + }); + }); +} diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index afbb18cd..b439ac63 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -26,6 +26,7 @@ class FakePostgresConnection extends PostgresConnection { int forceCloseCount = 0; int setReadOnlyCount = 0; bool? lastReadOnly; + bool? openTransaction; @override bool get isConnected => _connected; @@ -53,6 +54,9 @@ class FakePostgresConnection extends PostgresConnection { setReadOnlyCount++; lastReadOnly = readOnly; } + + @override + Future inOpenTransaction() async => openTransaction; } void main() { @@ -107,6 +111,40 @@ void main() { rw.release(); }); + test('tableWrite is a separate key from SQL readWrite', () async { + final created = []; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + created.add(c); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final sql = await pool.acquire(r, + database: 'postgres', mode: PgSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'postgres', mode: PgSessionMode.tableWrite); + final browse = await pool.acquire(r, + database: 'postgres', mode: PgSessionMode.readOnly); + expect(identical(sql.connection, grid.connection), isFalse); + expect(identical(sql.connection, browse.connection), isFalse); + expect(identical(grid.connection, browse.connection), isFalse); + expect(created.length, 3); + expect((grid.connection as FakePostgresConnection).lastReadOnly, isFalse); + expect( + (browse.connection as FakePostgresConnection).lastReadOnly, isTrue); + sql.release(); + grid.release(); + browse.release(); + }); + test('keyFor matches pool slot identity', () { final pool = PostgresConnectionPool( createAndConnect: (_, {required database, required mode}) async => @@ -120,6 +158,10 @@ void main() { pool.keyFor(null, 'postgres', PgSessionMode.readWrite), '0::postgres::readWrite', ); + expect( + pool.keyFor(1, 'app', PgSessionMode.tableWrite), + '1::app::tableWrite', + ); }); }); @@ -288,6 +330,82 @@ void main() { lease2.release(); }); + test('interrupt of SQL readWrite does not kill tableWrite or readOnly', + () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final sql = + await pool.acquire(r, database: 'app', mode: PgSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'app', mode: PgSessionMode.tableWrite); + final browse = + await pool.acquire(r, database: 'app', mode: PgSessionMode.readOnly); + final sqlFake = sql.connection as FakePostgresConnection; + final gridFake = grid.connection as FakePostgresConnection; + final browseFake = browse.connection as FakePostgresConnection; + + pool.interrupt(r, database: 'app', mode: PgSessionMode.readWrite); + expect(sqlFake.forceCloseCount, 1); + expect(gridFake.forceCloseCount, 0); + expect(browseFake.forceCloseCount, 0); + + pool.interruptAllModes(r, database: 'app'); + expect(gridFake.forceCloseCount, 1); + expect(browseFake.forceCloseCount, 1); + sql.release(); + grid.release(); + browse.release(); + }); + + test('hasOpenSqlTransaction reads the SQL slot only', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + expect( + await pool.hasOpenSqlTransaction(r, database: 'app'), + isFalse, + ); + + final sql = + await pool.acquire(r, database: 'app', mode: PgSessionMode.readWrite); + final sqlFake = sql.connection as FakePostgresConnection; + sqlFake.openTransaction = true; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isTrue); + expect( + await pool.hasOpenSqlTransaction(r, database: 'other'), + isFalse, + ); + + final grid = await pool.acquire(r, + database: 'app', mode: PgSessionMode.tableWrite); + (grid.connection as FakePostgresConnection).openTransaction = true; + sqlFake.openTransaction = false; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isFalse); + sql.release(); + grid.release(); + }); + test('disconnectAll forceCloses every pooled connection', () async { final fakes = []; Future factory( @@ -396,7 +514,8 @@ void main() { }); group('PostgresConnectionPool error wrapping', () { - test('wraps unexpected factory errors in PostgresConnectionException', () async { + test('wraps unexpected factory errors in PostgresConnectionException', + () async { Future factory( ConnectionRow row, { required String database, diff --git a/test/core/database/postgres_connection_string_parse_test.dart b/test/core/database/postgres_connection_string_parse_test.dart index bb4845d7..5f1f95f5 100644 --- a/test/core/database/postgres_connection_string_parse_test.dart +++ b/test/core/database/postgres_connection_string_parse_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:postgres/postgres.dart' show SslMode; // ignore: implementation_imports import 'package:postgres/src/connection_string.dart' show parseConnectionString; +import 'package:querya_desktop/core/database/postgres_connection.dart'; /// Ensures libpq-style URI params match what we document for [PostgresConnection.connect]. void main() { @@ -62,4 +63,60 @@ void main() { ); }); }); + + group('postgresResolveSslMode', () { + test('URI sslmode is source of truth', () { + expect( + postgresResolveSslMode( + uriSslMode: SslMode.require, + encrypt: true, + hasRootCert: true, + ), + SslMode.require, + ); + expect( + postgresResolveSslMode( + uriSslMode: SslMode.verifyFull, + encrypt: false, + hasRootCert: false, + ), + SslMode.verifyFull, + ); + }); + + test('host/port SSL without CA is require (encrypt only)', () { + expect( + postgresResolveSslMode(encrypt: true, hasRootCert: false), + SslMode.require, + ); + }); + + test('host/port with Root CA is verifyFull', () { + expect( + postgresResolveSslMode(encrypt: true, hasRootCert: true), + SslMode.verifyFull, + ); + expect( + postgresResolveSslMode(encrypt: false, hasRootCert: true), + SslMode.verifyFull, + ); + }); + + test('no SSL and no CA is disable', () { + expect( + postgresResolveSslMode(encrypt: false, hasRootCert: false), + SslMode.disable, + ); + }); + }); + + group('postgresReltuplesEstimate', () { + test('rounds planner floats and treats unanalyzed as unknown', () { + expect(postgresReltuplesEstimate(200.4), 200); + expect(postgresReltuplesEstimate(199.6), 200); + expect(postgresReltuplesEstimate(0), 0); + expect(postgresReltuplesEstimate(-1), isNull); + expect(postgresReltuplesEstimate(null), isNull); + }); + }); } 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 00000000..eeca3dd7 --- /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/core/database/postgres_sql_test.dart b/test/core/database/postgres_sql_test.dart index 85bf320a..efec0764 100644 --- a/test/core/database/postgres_sql_test.dart +++ b/test/core/database/postgres_sql_test.dart @@ -80,6 +80,118 @@ void main() { }); }); + group('applyPostgresTransactionSql', () { + test('BEGIN + SELECT stays open; COMMIT closes', () { + var open = false; + open = applyPostgresTransactionSql(open, 'BEGIN'); + expect(open, isTrue); + open = applyPostgresTransactionSql(open, 'SELECT 1'); + expect(open, isTrue); + open = applyPostgresTransactionSql(open, 'COMMIT'); + expect(open, isFalse); + }); + + test('BEGIN WORK opens; BEGINNING does not', () { + expect(applyPostgresTransactionSql(false, 'BEGIN WORK'), isTrue); + expect(applyPostgresTransactionSql(false, 'BEGINNING'), isFalse); + }); + + test('START TRANSACTION and END', () { + var open = applyPostgresTransactionSql(false, 'START TRANSACTION'); + expect(open, isTrue); + open = applyPostgresTransactionSql(open, 'END'); + expect(open, isFalse); + }); + + test('ROLLBACK closes; ROLLBACK TO savepoint does not', () { + expect(applyPostgresTransactionSql(true, 'ROLLBACK'), isFalse); + expect( + applyPostgresTransactionSql(true, 'ROLLBACK TO sp1'), + isTrue, + ); + expect( + applyPostgresTransactionSql(true, 'ROLLBACK TO SAVEPOINT sp1'), + isTrue, + ); + }); + + test('strips leading comments before BEGIN', () { + expect( + applyPostgresTransactionSql(false, '-- note\nBEGIN'), + isTrue, + ); + }); + }); + + group('kPostgresOpenTransactionProbeSql', () { + test('uses xact_start, not xid-if-assigned', () { + expect(kPostgresOpenTransactionProbeSql, contains('xact_start')); + expect(kPostgresOpenTransactionProbeSql, contains('pg_backend_pid()')); + expect( + kPostgresOpenTransactionProbeSql, + isNot(contains('pg_current_xact_id')), + ); + }); + }); + + group('runPostgresStatementsInTransaction', () { + test('executes BEGIN, each statement, COMMIT as separate calls', () async { + final calls = []; + await runPostgresStatementsInTransaction( + (sql) async => calls.add(sql), + [ + 'UPDATE public.t SET a = 1 WHERE id = 1', + 'DELETE FROM public.t WHERE id = 2', + ], + ); + expect(calls, [ + 'BEGIN', + 'UPDATE public.t SET a = 1 WHERE id = 1', + 'DELETE FROM public.t WHERE id = 2', + 'COMMIT', + ]); + }); + + test('ROLLBACK then rethrows when a statement fails', () async { + final calls = []; + await expectLater( + runPostgresStatementsInTransaction( + (sql) async { + calls.add(sql); + if (sql.startsWith('UPDATE')) { + throw StateError( + 'cannot insert multiple commands into a prepared statement', + ); + } + }, + ['UPDATE t SET a = 1 WHERE id = 1'], + ), + throwsA(isA()), + ); + expect(calls, [ + 'BEGIN', + 'UPDATE t SET a = 1 WHERE id = 1', + 'ROLLBACK', + ]); + }); + + test('ROLLBACK failure does not hide the original error', () async { + await expectLater( + runPostgresStatementsInTransaction( + (sql) async { + if (sql == 'BEGIN') return; + if (sql == 'ROLLBACK') throw StateError('already aborted'); + throw StateError('multi-command'); + }, + ['UPDATE t SET a = 1'], + ), + throwsA( + predicate((e) => e.message == 'multi-command'), + ), + ); + }); + }); + group('injectSqlLimit', () { test('appends LIMIT to select query without limit', () { expect(injectSqlLimit('SELECT * FROM users', 5000), diff --git a/test/core/database/redis_bulk_test.dart b/test/core/database/redis_bulk_test.dart new file mode 100644 index 00000000..54216604 --- /dev/null +++ b/test/core/database/redis_bulk_test.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:redis/redis.dart' as redis; + +void main() { + test('UTF-8 string reply stays text, not List.toString', () { + final v = RedisBulkValue.fromReply('hello'); + expect(v.isUtf8, isTrue); + expect(v.text, 'hello'); + expect(v.label, 'hello'); + expect(v.commandArg, 'hello'); + }); + + test('UTF-8 bulk bytes decode to text', () { + final v = RedisBulkValue.fromReply(utf8.encode('café')); + expect(v.isUtf8, isTrue); + expect(v.text, 'café'); + expect(v.label, 'café'); + }); + + test('non-UTF-8 bulk is hex/base64, not replacement characters', () { + final raw = Uint8List.fromList(const [0xff, 0xfe, 0x01, 0x1f, 0x8b]); + final v = RedisBulkValue.fromReply(raw); + expect(v.isUtf8, isFalse); + expect(v.text, isNull); + expect(v.label, '0xfffe011f8b'); + expect(v.toHex(), 'fffe011f8b'); + expect(v.toBase64(), base64Encode(raw)); + expect(v.label.contains('['), isFalse); + expect(v.commandArg, isA()); + }); + + test('SCAN cursor bulk digits parse as int, not List.toString', () { + expect(redisReplyInt(utf8.encode('42')), 42); + expect(redisReplyInt(42), 42); + expect(redisReplyInt('7'), 7); + }); +} diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index c742260f..8362a7fc 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -127,6 +127,60 @@ void main() { expect(conn.password, 'pass'); expect(conn.connectionString, contains('sslrootcert')); }); + + test('URI-only row (null host/port) still uses URI host, port, and TLS', + () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + id: 8, + type: 'redis', + name: 'uri-only', + useSSL: false, + connectionString: + 'rediss://user:s3cret@cache.example.com:6380?sslrootcert=%2Fca.pem', + createdAt: '0', + ), + ); + expect(conn.host, 'cache.example.com'); + expect(conn.port, 6380); + expect(conn.useSSL, isTrue); + expect(conn.username, 'user'); + expect(conn.password, 's3cret'); + expect(conn.connectionString, contains('sslrootcert')); + }); + + test('sidebar probe id override does not use the saved connection id', () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + id: 42, + type: 'redis', + name: 'prod', + connectionString: 'rediss://cache.example.com:6380', + createdAt: '0', + ), + id: -1, + ); + expect(conn.id, -1); + expect(conn.host, 'cache.example.com'); + expect(conn.port, 6380); + expect(conn.useSSL, isTrue); + }); + + test('host/port form without URI stays on those fields', () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + type: 'redis', + name: 'local', + host: '127.0.0.1', + port: 6379, + useSSL: false, + createdAt: '0', + ), + ); + expect(conn.host, '127.0.0.1'); + expect(conn.port, 6379); + expect(conn.useSSL, isFalse); + }); }); group('RedisConnectionException', () { @@ -136,4 +190,71 @@ void main() { expect(ex.toString(), 'something went wrong'); }); }); + + group('RedisConnection clientReadOnly', () { + test('SET and DEL throw while the session lock is on', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + await fake.applyClientReadOnly(true); + + expect(fake.clientReadOnly, isTrue); + await expectLater( + fake.set('k', 'v'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Redis connection is read-only', + ), + ), + ); + await expectLater(fake.del('k'), throwsA(isA())); + + await fake.applyClientReadOnly(false); + expect(fake.clientReadOnly, isFalse); + expect(await fake.del('k'), 0); + await fake.disconnect(); + }); + }); + + group('RedisConnection collection paging', () { + test('LRANGE first page is capped and HGETALL is refused by the fake', + () async { + final fake = RedisConnectionTestFake( + listItems: List.generate(250, (i) => 'item_$i'), + ); + await fake.connect(); + + final page = await fake.lrange('k', 0, redisCollectionPageSize - 1); + expect(page, hasLength(redisCollectionPageSize)); + expect(page.first.text, 'item_0'); + expect(page.last.text, 'item_199'); + expect(await fake.llen('k'), 250); + await expectLater(fake.hgetall('k'), throwsStateError); + await fake.disconnect(); + }); + + test('HSCAN returns pages instead of HGETALL', () async { + final fake = RedisConnectionTestFake( + hashFirstPage: const {'a': '1', 'b': '2'}, + hashSecondPage: const {'c': '3'}, + ); + await fake.connect(); + + final (c1, p1) = await fake.hscan('h'); + expect(c1, 1); + expect( + {for (final e in p1.entries) e.key.text: e.value.text}, + {'a': '1', 'b': '2'}, + ); + final (c2, p2) = await fake.hscan('h', cursor: c1); + expect(c2, 0); + expect( + {for (final e in p2.entries) e.key.text: e.value.text}, + {'c': '3'}, + ); + expect(await fake.hlen('h'), 3); + await fake.disconnect(); + }); + }); } diff --git a/test/core/database/redis_service_test.dart b/test/core/database/redis_service_test.dart index b14e1a23..5408cc52 100644 --- a/test/core/database/redis_service_test.dart +++ b/test/core/database/redis_service_test.dart @@ -161,4 +161,108 @@ void main() { expect(RedisService.instance.getConnection(202), isNull); }); }); + + group('RedisService role pool', () { + ConnectionRow row(int id, {String name = 'r'}) => ConnectionRow( + id: id, + type: 'redis', + name: name, + createdAt: '2026-01-01T00:00:00Z', + ); + + test('stats and explorer are independent sockets', () { + const id = 8120; + final stats = RedisService.instance.acquire( + row(id, name: 'stats'), + role: RedisSessionRole.stats, + ); + final explorer = RedisService.instance.acquire( + row(id, name: 'explorer'), + role: RedisSessionRole.explorer, + ); + + expect(stats, isNot(same(explorer))); + expect( + RedisService.instance.getConnection(id), + same(stats), + ); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + same(explorer), + ); + }); + + test('acquire reuses the socket for the same role', () { + const id = 8121; + final first = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.stats, + ); + final second = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.stats, + ); + expect(second, same(first)); + }); + + test('createConnection replaces stats without dropping explorer', () { + const id = 8122; + final explorer = RedisService.instance.acquire( + row(id, name: 'explorer'), + role: RedisSessionRole.explorer, + ); + RedisService.instance.createConnection(row(id, name: 'old-stats')); + final stats2 = RedisService.instance.createConnection( + row(id, name: 'new-stats'), + ); + + expect( + RedisService.instance.getConnection(id), + same(stats2), + ); + expect(stats2.name, 'new-stats'); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + same(explorer), + ); + }); + + test('disconnect removes only that instance', () async { + const id = 8123; + final stats = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.stats, + ); + final explorer = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.explorer, + ); + + await RedisService.instance.disconnect(stats); + + expect(RedisService.instance.getConnection(id), isNull); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + same(explorer), + ); + }); + + test('disconnectByConnectionId closes every role', () async { + const id = 8124; + RedisService.instance.acquire(row(id), role: RedisSessionRole.stats); + RedisService.instance.acquire(row(id), role: RedisSessionRole.explorer); + + await RedisService.instance.disconnectByConnectionId(id); + + expect(RedisService.instance.getConnection(id), isNull); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + isNull, + ); + }); + }); } diff --git a/test/core/database/redis_set_keepttl_test.dart b/test/core/database/redis_set_keepttl_test.dart new file mode 100644 index 00000000..a5cce05f --- /dev/null +++ b/test/core/database/redis_set_keepttl_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; + +class _SetCommandFake extends RedisConnectionTestFake { + final List> commands = []; + bool rejectKeepTtl = false; + int ttlResult = 300; + Object? setResult = 'OK'; + + @override + Future sendCommand(List args) async { + commands.add(List.from(args)); + final op = args.first.toString().toUpperCase(); + if (op == 'SET') { + if (rejectKeepTtl && + args.any((a) => a.toString().toUpperCase() == 'KEEPTTL')) { + throw RedisConnectionException('ERR syntax error'); + } + return setResult; + } + if (op == 'TTL') return ttlResult; + return super.sendCommand(args); + } +} + +void main() { + group('RedisConnection.set KEEPTTL', () { + test('Save without EX uses SET KEEPTTL XX', () async { + final fake = _SetCommandFake(); + await fake.connect(); + + await fake.set('session:1', 'new-value'); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ]); + }); + + test('does not recreate a key that already expired (XX miss)', () async { + final fake = _SetCommandFake()..setResult = null; + await fake.connect(); + + await expectLater( + fake.set('session:1', 'new-value'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Key no longer exists', + ), + ), + ); + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ]); + }); + + test('Redis < 6: TTL then SET EX when KEEPTTL is rejected', () async { + final fake = _SetCommandFake() + ..rejectKeepTtl = true + ..ttlResult = 300; + await fake.connect(); + + await fake.set('session:1', 'new-value'); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ['TTL', 'session:1'], + ['SET', 'session:1', 'new-value', 'EX', 300], + ]); + }); + + test('Redis < 6: does not SET when TTL is -2', () async { + final fake = _SetCommandFake() + ..rejectKeepTtl = true + ..ttlResult = -2; + await fake.connect(); + + await expectLater( + fake.set('session:1', 'new-value'), + throwsA(isA()), + ); + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ['TTL', 'session:1'], + ]); + }); + + test('Redis < 6: plain SET when key has no expiry (TTL -1)', () async { + final fake = _SetCommandFake() + ..rejectKeepTtl = true + ..ttlResult = -1; + await fake.connect(); + + await fake.set('session:1', 'new-value'); + + expect(fake.commands.last, ['SET', 'session:1', 'new-value']); + }); + + test('explicit ttlSeconds still sends SET EX', () async { + final fake = _SetCommandFake(); + await fake.connect(); + + await fake.set('session:1', 'new-value', ttlSeconds: 10); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'EX', 10], + ]); + }); + + test('keepTtl: false sends a plain SET', () async { + final fake = _SetCommandFake(); + await fake.connect(); + + await fake.set('session:1', 'new-value', keepTtl: false); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value'], + ]); + }); + }); +} diff --git a/test/core/database/redis_types_and_ttls_test.dart b/test/core/database/redis_types_and_ttls_test.dart index b7067209..b472e31e 100644 --- a/test/core/database/redis_types_and_ttls_test.dart +++ b/test/core/database/redis_types_and_ttls_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; /// Counts outbound commands to prove [typesAndTtls] fires TYPE+TTL without @@ -22,7 +23,11 @@ void main() { final fake = _CountingRedisFake(); await fake.connect(); - final metas = await fake.typesAndTtls(['a', 'b', 'c']); + final metas = await fake.typesAndTtls([ + RedisBulkValue.utf8('a'), + RedisBulkValue.utf8('b'), + RedisBulkValue.utf8('c'), + ]); expect(metas, hasLength(3)); expect(metas.map((m) => m.type), everyElement('string')); diff --git a/test/core/database/sql_table_target_extractor_test.dart b/test/core/database/sql_table_target_extractor_test.dart index 8bf3a4f7..378a3bac 100644 --- a/test/core/database/sql_table_target_extractor_test.dart +++ b/test/core/database/sql_table_target_extractor_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; void main() { group('SqlTableTargetExtractor', () { @@ -11,27 +13,219 @@ void main() { }); test('extracts schema and table from quoted identifiers', () { - final target = SqlTableTargetExtractor.extract('SELECT id, name FROM "public"."accounts" WHERE id > 10'); + final target = SqlTableTargetExtractor.extract( + 'SELECT id, name FROM "public"."accounts" WHERE id > 10'); expect(target, isNotNull); expect(target!.tableName, 'accounts'); expect(target.schema, 'public'); }); test('extracts schema and table from MySQL backticks', () { - final target = SqlTableTargetExtractor.extract('SELECT * FROM `shop_db`.`orders` ORDER BY date DESC'); + final target = SqlTableTargetExtractor.extract( + 'SELECT * FROM `shop_db`.`orders` ORDER BY date DESC'); expect(target, isNotNull); expect(target!.tableName, 'orders'); expect(target.schema, 'shop_db'); }); - test('returns null for queries with JOINs to avoid ambiguous mutations', () { - final target = SqlTableTargetExtractor.extract('SELECT u.id, o.amount FROM users u JOIN orders o ON u.id = o.user_id'); + test('extracts public.t from a simple qualified FROM', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM public.t'); + expect(target, const SqlTableTarget(tableName: 't', schema: 'public')); + }); + + test('returns null for queries with JOINs to avoid ambiguous mutations', + () { + final target = SqlTableTargetExtractor.extract( + 'SELECT u.id, o.amount FROM users u JOIN orders o ON u.id = o.user_id'); expect(target, isNull); }); + test('returns null for comma FROM', () { + expect( + SqlTableTargetExtractor.extract('SELECT * FROM a, b'), + isNull, + ); + expect( + SqlTableTargetExtractor.extract( + 'SELECT * FROM users u, orders o WHERE u.id = o.user_id'), + isNull, + ); + }); + + test('returns null for subquery FROM', () { + expect( + SqlTableTargetExtractor.extract( + 'SELECT * FROM (SELECT * FROM users) x'), + isNull, + ); + }); + + test('returns null for VALUES', () { + expect(SqlTableTargetExtractor.extract('VALUES (1, 2)'), isNull); + expect( + SqlTableTargetExtractor.extract('SELECT * FROM (VALUES (1)) v'), + isNull, + ); + }); + test('returns null for empty or non-FROM queries', () { expect(SqlTableTargetExtractor.extract(''), isNull); expect(SqlTableTargetExtractor.extract('SELECT 1 + 1'), isNull); }); + + test('ignores FROM inside extract() and string literals', () { + final target = SqlTableTargetExtractor.extract( + "SELECT extract(epoch FROM created_at), name FROM events WHERE name = 'join'", + ); + expect(target, const SqlTableTarget(tableName: 'events')); + }); + }); + + group('sqlResultGridSaveEnabled', () { + test('true for simple SELECT when PK columns are in the result', () { + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM public.t', + resultColumns: ['id', 'name'], + primaryKeys: ['id'], + ), + isTrue, + ); + }); + + test('false for JOIN and comma FROM', () { + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM a JOIN b ON a.id = b.id', + resultColumns: ['id'], + primaryKeys: ['id'], + ), + isFalse, + ); + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM a, b', + resultColumns: ['id'], + primaryKeys: ['id'], + ), + isFalse, + ); + }); + + test('false when PK is missing from the result or empty', () { + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT name FROM public.t', + resultColumns: ['name'], + primaryKeys: ['id'], + ), + isFalse, + ); + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM public.t', + resultColumns: ['id', 'name'], + primaryKeys: [], + ), + isFalse, + ); + }); + }); + + group('SQL-grid DML from extracted target', () { + test('JOIN and comma FROM do not produce DML', () { + for (final sql in [ + 'SELECT * FROM a JOIN b ON a.id = b.id', + 'SELECT * FROM a, b', + ]) { + expect(SqlTableTargetExtractor.extract(sql), isNull); + } + }); + + test('simple SELECT * FROM public.t uses the PK and column types', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM public.t'); + expect(target, const SqlTableTarget(tableName: 't', schema: 'public')); + + final buffer = DataGridStagingBuffer( + columns: const ['id', 'zip'], + rows: const [ + ['1', '01234'], + ], + ); + buffer.setCell(0, 1, '00789'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: target!.tableName, + schema: target.schema, + primaryKeys: const ['id'], + columnDataTypes: const {'id': 'integer', 'zip': 'text'}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.single.sql, + 'UPDATE "public"."t" SET "zip" = \'00789\' WHERE "id" = 1', + ); + expect(plan.statements.single.sql, isNot(contains('"zip" = \'01234\''))); + }); + + test('simple SELECT * FROM t uses the SQLite PK and column types', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM t'); + expect(target, const SqlTableTarget(tableName: 't')); + + final buffer = DataGridStagingBuffer( + columns: const ['id', 'zip'], + rows: const [ + ['1', '01234'], + ], + ); + buffer.setCell(0, 1, '00789'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: target!.tableName, + primaryKeys: const ['id'], + columnDataTypes: const {'id': 'INTEGER', 'zip': 'TEXT'}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.single.sql, + 'UPDATE "t" SET "zip" = \'00789\' WHERE "id" = 1', + ); + expect(plan.toTransactionSql(), startsWith('BEGIN TRANSACTION;')); + expect(plan.statements.single.sql, isNot(contains('"zip" = \'01234\''))); + }); + + test('simple SELECT * FROM db.t uses the MySQL PK and column types', () { + final target = + SqlTableTargetExtractor.extract('SELECT * FROM db.t'); + expect(target, const SqlTableTarget(tableName: 't', schema: 'db')); + + final buffer = DataGridStagingBuffer( + columns: const ['id', 'zip'], + rows: const [ + ['1', '01234'], + ], + ); + buffer.setCell(0, 1, '00789'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.mysql, + tableName: target!.tableName, + schema: target.schema, + primaryKeys: const ['id'], + columnDataTypes: const {'id': 'int', 'zip': 'varchar'}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.single.sql, + 'UPDATE `db`.`t` SET `zip` = \'00789\' WHERE `id` = 1', + ); + expect(plan.toTransactionSql(), startsWith('START TRANSACTION;')); + expect(plan.statements.single.sql, isNot(contains('`zip` = \'01234\''))); + }); }); } diff --git a/test/core/database/sqlite_connection_pool_test.dart b/test/core/database/sqlite_connection_pool_test.dart index 1b2b978e..c476f29f 100644 --- a/test/core/database/sqlite_connection_pool_test.dart +++ b/test/core/database/sqlite_connection_pool_test.dart @@ -22,6 +22,7 @@ class FakeSqliteConnection extends SqliteConnection { int connectCount = 0; int disconnectCount = 0; int forceCloseCount = 0; + bool? openTransaction; @override bool get isConnected => _connected; @@ -43,6 +44,9 @@ class FakeSqliteConnection extends SqliteConnection { forceCloseCount++; _connected = false; } + + @override + Future inOpenTransaction() async => openTransaction; } void main() { @@ -99,7 +103,8 @@ void main() { ); }); - test('rethrows SqliteConnectionException wrapped on acquire error', () async { + test('rethrows SqliteConnectionException wrapped on acquire error', + () async { final pool = SqliteConnectionPool( createAndConnect: (row, {required mode}) async { throw Exception('network boom'); @@ -117,7 +122,8 @@ void main() { final fake2 = FakeSqliteConnection(id: 2); var i = 0; final pool = SqliteConnectionPool( - createAndConnect: (row, {required mode}) async => i++ == 0 ? fake1 : fake2, + createAndConnect: (row, {required mode}) async => + i++ == 0 ? fake1 : fake2, ); await pool.acquire(_row(id: 1)); @@ -127,5 +133,81 @@ void main() { expect(fake1.forceCloseCount, 1); expect(fake2.forceCloseCount, 1); }); + + test('tableWrite is a separate key from SQL readWrite', () async { + final created = []; + final pool = SqliteConnectionPool( + createAndConnect: (row, {required mode}) async { + final c = FakeSqliteConnection(); + await c.connect(); + created.add(c); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, mode: SqliteSessionMode.readWrite); + final grid = await pool.acquire(r, mode: SqliteSessionMode.tableWrite); + final browse = await pool.acquire(r, mode: SqliteSessionMode.readOnly); + expect(identical(sql.connection, grid.connection), isFalse); + expect(identical(sql.connection, browse.connection), isFalse); + expect(created.length, 3); + expect(pool.keyFor(1, SqliteSessionMode.tableWrite), '1::tableWrite'); + sql.release(); + grid.release(); + browse.release(); + }); + + test('interrupt of SQL readWrite does not kill tableWrite or readOnly', + () async { + final pool = SqliteConnectionPool( + createAndConnect: (row, {required mode}) async { + final c = FakeSqliteConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, mode: SqliteSessionMode.readWrite); + final grid = await pool.acquire(r, mode: SqliteSessionMode.tableWrite); + final browse = await pool.acquire(r, mode: SqliteSessionMode.readOnly); + final sqlFake = sql.connection as FakeSqliteConnection; + final gridFake = grid.connection as FakeSqliteConnection; + final browseFake = browse.connection as FakeSqliteConnection; + + pool.interrupt(r, mode: SqliteSessionMode.readWrite); + expect(sqlFake.forceCloseCount, 1); + expect(gridFake.forceCloseCount, 0); + expect(browseFake.forceCloseCount, 0); + + pool.interruptAllModes(r); + expect(gridFake.forceCloseCount, 1); + expect(browseFake.forceCloseCount, 1); + sql.release(); + grid.release(); + browse.release(); + }); + + test('hasOpenSqlTransaction reads the SQL slot only', () async { + final pool = SqliteConnectionPool( + createAndConnect: (row, {required mode}) async { + final c = FakeSqliteConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + expect(await pool.hasOpenSqlTransaction(r), isFalse); + + final sql = await pool.acquire(r, mode: SqliteSessionMode.readWrite); + (sql.connection as FakeSqliteConnection).openTransaction = true; + expect(await pool.hasOpenSqlTransaction(r), isTrue); + + final grid = await pool.acquire(r, mode: SqliteSessionMode.tableWrite); + (grid.connection as FakeSqliteConnection).openTransaction = true; + (sql.connection as FakeSqliteConnection).openTransaction = false; + expect(await pool.hasOpenSqlTransaction(r), isFalse); + sql.release(); + grid.release(); + }); }); } diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index d00cfbe9..be7e65e2 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -1,7 +1,14 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/storage/local_db.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/table_mutation_engine.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/table_view_staging.dart'; void main() { setUpAll(() { @@ -40,6 +47,50 @@ void main() { }); }); + group('sqliteOpenReadOnly', () { + const rw = ConnectionRow( + id: 1, + type: 'sqlite', + name: 'rw', + host: '/tmp/rw.db', + createdAt: '', + ); + const formRo = ConnectionRow( + id: 2, + type: 'sqlite', + name: 'ro', + host: '/tmp/ro.db', + useSSL: true, + createdAt: '', + ); + + test('form read-only opens SQLITE_OPEN_READONLY even for tableWrite', () { + expect( + sqliteOpenReadOnly(row: formRo, mode: SqliteSessionMode.tableWrite), + isTrue, + ); + expect( + sqliteOpenReadOnly(row: formRo, mode: SqliteSessionMode.readWrite), + isTrue, + ); + }); + + test('writable form uses read-write only for tableWrite/readWrite', () { + expect( + sqliteOpenReadOnly(row: rw, mode: SqliteSessionMode.readOnly), + isTrue, + ); + expect( + sqliteOpenReadOnly(row: rw, mode: SqliteSessionMode.tableWrite), + isFalse, + ); + expect( + sqliteOpenReadOnly(row: rw, mode: SqliteSessionMode.readWrite), + isFalse, + ); + }); + }); + group('SqliteConnection operations (In-Memory)', () { late SqliteConnection conn; @@ -76,16 +127,38 @@ void main() { expect(res.first['timeout'], 5000); }); + test('enables WAL on a file database', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_wal_'); + final path = '${dir.path}/t.db'; + final fileConn = SqliteConnection( + id: 9, + name: 'wal_file', + path: path, + createIfMissing: true, + ); + addTearDown(() async { + await fileConn.disconnect(); + await dir.delete(recursive: true); + }); + await fileConn.connect(); + final res = await fileConn.execute('PRAGMA journal_mode'); + expect( + res.first['journal_mode']?.toString().toLowerCase(), + 'wal', + ); + }); + test('testConnection connects, queries and cleans up', () async { - final ok = await conn.testConnection(); - expect(ok, true); + final result = await conn.testConnection(); + expect(result.ok, isTrue); expect(conn.isConnected, false); // should be disconnected afterwards }); test('lists tables, views, and columns correctly', () async { await conn.connect(); - await conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + await conn + .execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); await conn.execute('CREATE VIEW user_names AS SELECT name FROM users'); final tables = await conn.listTables(); @@ -98,25 +171,139 @@ void main() { expect(columns, containsAll(['id', 'name'])); }); - test('executes INSERT, UPDATE, DELETE with RETURNING clause correctly', () async { + test('Table Browser UPDATE on implicit rowid table round-trips', () async { + await conn.connect(); + await conn.execute('CREATE TABLE t (name TEXT)'); + await conn.execute("INSERT INTO t (name) VALUES ('Ada')"); + + final schema = await conn.getTableSchema(table: 't'); + expect(schema.primaryKeys, isEmpty); + + final pks = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: false, + ); + expect(pks, ['rowid']); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: pks.isNotEmpty, + ), + isTrue, + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: pks.isNotEmpty, + schemaLoaded: true, + ), + isNull, + ); + + final sql = sqliteBrowseDataSql( + qualifiedFrom: SqliteConnection.quoteIdentifier('t'), + primaryKeys: pks, + isView: false, + limit: 200, + offset: 0, + ); + final rs = await conn.execute(sql); + expect(rs, isNotEmpty); + final cols = rs.first.keys.toList(); + expect(cols, contains('rowid')); + expect(cols, contains('name')); + + final rows = [ + for (final row in rs) + [for (final c in cols) '${row[c]}'], + ]; + final buffer = DataGridStagingBuffer(columns: cols, rows: rows); + addTearDown(buffer.dispose); + buffer.setCell(0, cols.indexOf('name'), 'Grace'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 't', + primaryKeys: pks, + columnDataTypes: { + kSqliteImplicitRowid: 'INTEGER', + 'name': 'TEXT', + }, + columnMeta: {kSqliteImplicitRowid: sqliteImplicitRowidColumn}, + ); + expect(plan.statements, hasLength(1)); + expect(plan.statements.first.sql, contains('WHERE "rowid" =')); + + expect(await conn.executeAffected(plan.statements.first.sql), 1); + final after = await conn.execute('SELECT name FROM t'); + expect(after.first['name'], 'Grace'); + }); + + test('WITHOUT ROWID tables keep the declared PK, not implicit rowid', + () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE wr (id INTEGER PRIMARY KEY, name TEXT) WITHOUT ROWID', + ); + final schema = await conn.getTableSchema(table: 'wr'); + expect(schema.primaryKeys, ['id']); + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: false, + ), + ['id'], + ); + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"wr"', + primaryKeys: schema.primaryKeys, + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "wr" ORDER BY "id" LIMIT 200 OFFSET 0', + ); + }); + + test('getObjectDdl returns CREATE SQL for a table that exists', () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT)', + ); + + final ddl = await conn.getObjectDdl('foo'); + expect(ddl, isNot(contains('No definition found'))); + expect(ddl.toUpperCase(), contains('CREATE TABLE')); + expect(ddl.toLowerCase(), contains('foo')); + }); + + test('executes INSERT, UPDATE, DELETE with RETURNING clause correctly', + () async { await conn.connect(); - await conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); + await conn.execute( + 'CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); // INSERT with RETURNING - final insertRes = await conn.execute("INSERT INTO users (name) VALUES ('Alice') RETURNING id, name"); + final insertRes = await conn.execute( + "INSERT INTO users (name) VALUES ('Alice') RETURNING id, name"); expect(insertRes, isNotEmpty); expect(insertRes.first['id'], 1); expect(insertRes.first['name'], 'Alice'); // UPDATE with RETURNING - final updateRes = await conn.execute("UPDATE users SET name = 'Bob' WHERE id = 1 RETURNING id, name"); + final updateRes = await conn.execute( + "UPDATE users SET name = 'Bob' WHERE id = 1 RETURNING id, name"); expect(updateRes, isNotEmpty); expect(updateRes.first['id'], 1); expect(updateRes.first['name'], 'Bob'); // DELETE with RETURNING - final deleteRes = await conn.execute("DELETE FROM users WHERE id = 1 RETURNING id, name"); + final deleteRes = await conn + .execute("DELETE FROM users WHERE id = 1 RETURNING id, name"); expect(deleteRes, isNotEmpty); expect(deleteRes.first['id'], 1); expect(deleteRes.first['name'], 'Bob'); @@ -145,9 +332,227 @@ void main() { await roConn.disconnect(); }); + test('read-only rejects WITH INSERT; PRAGMA busy_timeout still allowed', + () async { + final roConn = SqliteConnection( + id: 3, + name: 'in_memory_ro_with', + path: inMemoryDatabasePath, + readOnly: true, + ); + await roConn.connect(); + addTearDown(roConn.disconnect); + + expect( + await roConn.execute('PRAGMA busy_timeout'), + isNotEmpty, + ); + expect( + await roConn.execute('WITH x AS (SELECT 1) SELECT * FROM x'), + isNotEmpty, + ); + expect( + () => roConn.execute( + 'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x', + ), + throwsA(isA()), + ); + expect( + () => roConn.execute('PRAGMA journal_mode=WAL'), + throwsA(isA()), + ); + }); + + test('WITH INSERT on a writable connection applies DML', () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE w (id INTEGER PRIMARY KEY, n INTEGER)', + ); + await conn.execute( + 'WITH x AS (SELECT 7 AS n) INSERT INTO w (n) SELECT n FROM x', + ); + final rows = await conn.execute('SELECT n FROM w'); + expect(rows, hasLength(1)); + expect(rows.first['n'], 7); + }); + + test('tracks BEGIN/COMMIT and refuses nested BEGIN via runInTransaction', + () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)', + ); + await conn.execute('BEGIN'); + expect(await conn.inOpenTransaction(), isTrue); + + await conn.runInTransaction(() async { + await conn.execute("INSERT INTO t (name) VALUES ('a')"); + }); + expect(await conn.inOpenTransaction(), isTrue); + await conn.execute('COMMIT'); + expect(await conn.inOpenTransaction(), isFalse); + + final rows = await conn.execute('SELECT COUNT(*) AS c FROM t'); + expect(rows.first['c'], 1); + + await conn.runInTransaction(() async { + await conn.execute("INSERT INTO t (name) VALUES ('b')"); + }); + expect(await conn.inOpenTransaction(), isFalse); + final rows2 = await conn.execute('SELECT COUNT(*) AS c FROM t'); + expect(rows2.first['c'], 2); + }); + + test('runInTransaction rolls back when a later statement fails', () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL)', + ); + await conn.execute("INSERT INTO t (id, name) VALUES (1, 'keep')"); + + await expectLater( + conn.runInTransaction(() async { + expectDmlMatchedRows( + await conn.executeAffected( + "UPDATE t SET name = 'changed' WHERE id = 1", + ), + ); + expectDmlMatchedRows( + await conn.executeAffected( + "UPDATE t SET name = 'ghost' WHERE id = 999", + ), + ); + }), + throwsA(isA()), + ); + + expect(await conn.inOpenTransaction(), isFalse); + final rows = await conn.execute('SELECT id, name FROM t'); + expect(rows, hasLength(1)); + expect(rows.first['name'], 'keep'); + }); + test('handles quotes in quoteIdentifier helper', () { - expect(SqliteConnection.quoteIdentifier('normal_table'), '"normal_table"'); - expect(SqliteConnection.quoteIdentifier('table"with"quotes'), '"table""with""quotes"'); + expect( + SqliteConnection.quoteIdentifier('normal_table'), '"normal_table"'); + expect(SqliteConnection.quoteIdentifier('table"with"quotes'), + '"table""with""quotes"'); + }); + }); + + group('SqliteConnection missing file (#798)', () { + test('connect on a missing path does not create the file', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_miss_'); + final path = '${dir.path}/oops.db'; + final conn = SqliteConnection( + id: 1, + name: 'missing', + path: path, + ); + addTearDown(() async { + await conn.disconnect(); + await dir.delete(recursive: true); + }); + + await expectLater( + conn.connect(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not found'), + ), + ), + ); + expect(File(path).existsSync(), isFalse); + expect(conn.isConnected, isFalse); + }); + + test('testConnection on a missing path does not create the file', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_test_'); + final path = '${dir.path}/oops.db'; + final conn = SqliteConnection( + id: 1, + name: 'missing', + path: path, + ); + addTearDown(() async { + await conn.disconnect(); + await dir.delete(recursive: true); + }); + + final result = await conn.testConnection(); + expect(result.ok, isFalse); + expect(result.error, contains('not found')); + expect(File(path).existsSync(), isFalse); + }); + + test('createIfMissing creates an empty database', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_new_'); + final path = '${dir.path}/new.db'; + addTearDown(() async { + await dir.delete(recursive: true); + }); + + await SqliteConnection.createFileIfMissing(path); + expect(File(path).existsSync(), isTrue); + + final conn = SqliteConnection( + id: 1, + name: 'created', + path: path, + ); + addTearDown(conn.disconnect); + await conn.connect(); + expect(conn.isConnected, isTrue); + }); + + test('corrupt file is reported as not a database', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_bad_'); + final path = '${dir.path}/junk.db'; + await File(path).writeAsString('this is not sqlite'); + final conn = SqliteConnection( + id: 1, + name: 'junk', + path: path, + ); + addTearDown(() async { + await conn.disconnect(); + await dir.delete(recursive: true); + }); + + await expectLater( + conn.connect(), + throwsA( + isA().having( + (e) => e.message.toLowerCase(), + 'message', + anyOf(contains('corrupt'), contains('not a database')), + ), + ), + ); + expect(conn.isConnected, isFalse); + }); + + test('sqliteMapOpenError distinguishes missing, permission, corrupt', () { + expect( + sqliteMapOpenError(StateError('file /x not found'), '/x').message, + 'SQLite file not found: /x', + ); + expect( + sqliteMapOpenError( + Exception('OS Error: Permission denied, errno = 13'), + '/x', + ).message, + 'Permission denied opening SQLite file: /x', + ); + expect( + sqliteMapOpenError( + Exception('file is not a database'), + '/x', + ).message, + 'SQLite database is corrupt or not a database: /x', + ); }); }); } diff --git a/test/core/database/sqlite_sql_test.dart b/test/core/database/sqlite_sql_test.dart new file mode 100644 index 00000000..f2fc4043 --- /dev/null +++ b/test/core/database/sqlite_sql_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sqlite_sql.dart'; + +void main() { + group('sqliteSqlIsReadOnlyQuery', () { + test('SELECT / VALUES / EXPLAIN are read-only', () { + expect(sqliteSqlIsReadOnlyQuery('SELECT 1'), isTrue); + expect(sqliteSqlIsReadOnlyQuery(' values (1)'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('EXPLAIN SELECT 1'), isTrue); + }); + + test('strips leading comments before the first keyword', () { + expect(sqliteSqlIsReadOnlyQuery('-- c\nSELECT 1'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('/* c */ SELECT 1'), isTrue); + expect( + sqliteSqlIsReadOnlyQuery( + '-- c\nWITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x'), + isFalse, + ); + }); + + test('WITH SELECT / VALUES is read-only; WITH INSERT is a write', () { + expect( + sqliteSqlIsReadOnlyQuery('WITH x AS (SELECT 1) SELECT * FROM x'), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS (SELECT 1), y AS (SELECT 2) SELECT * FROM x', + ), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH RECURSIVE x(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM x WHERE n<3) SELECT * FROM x', + ), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS NOT MATERIALIZED (SELECT 1) SELECT * FROM x', + ), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x', + ), + isFalse, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS (SELECT 1) UPDATE t SET a = 1', + ), + isFalse, + ); + }); + + test('PRAGMA query is read-only; assignment is a write', () { + expect(sqliteSqlIsReadOnlyQuery('PRAGMA busy_timeout'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA table_info(users)'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA busy_timeout=5000'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA journal_mode=WAL'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA journal_mode = WAL'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA foreign_keys=ON'), isFalse); + }); + + test('INSERT / CREATE are writes', () { + expect(sqliteSqlIsReadOnlyQuery('INSERT INTO t VALUES (1)'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('CREATE TABLE t (id INT)'), isFalse); + }); + }); +} diff --git a/test/core/database/stream_take_drain_test.dart b/test/core/database/stream_take_drain_test.dart new file mode 100644 index 00000000..270a10bb --- /dev/null +++ b/test/core/database/stream_take_drain_test.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/database/stream_take_drain.dart'; + +void main() { + group('takeThenDrain', () { + test('keeps first cap items and drains the rest so the producer can finish', + () async { + final controller = StreamController(); + final future = takeThenDrain(controller.stream, 2); + + controller.add(1); + controller.add(2); + // Must not throw: cancelling at the cap would make this add fail. + controller.add(3); + await controller.close(); + + final taken = await future; + expect(taken.items, [1, 2]); + expect(taken.truncated, isTrue); + expect(controller.hasListener, isFalse); + }); + + test('does not mark truncated when the stream ends at the cap', () async { + final taken = await takeThenDrain(Stream.fromIterable([1, 2]), 2); + expect(taken.items, [1, 2]); + expect(taken.truncated, isFalse); + }); + + test('empty stream', () async { + final taken = await takeThenDrain(const Stream.empty(), 5); + expect(taken.items, isEmpty); + expect(taken.truncated, isFalse); + }); + + test('after drain, a following stream can be consumed (next query)', + () async { + final first = await takeThenDrain( + Stream.fromIterable([1, 2, 3]), + 2, + ); + expect(first.items, [1, 2]); + + final second = await takeThenDrain( + Stream.fromIterable([99]), + 5000, + ); + expect(second.items, [99]); + expect(second.truncated, isFalse); + }); + }); + + group('injectSqlLimit + takeThenDrain (MySQL SELECT cap)', () { + test('SELECT of N+1 rows with cap N; following SELECT 1 succeeds', + () async { + const userSql = 'SELECT n FROM t'; + const cap = 2; + final executed = injectSqlLimit(userSql, cap); + expect(executed, contains('LIMIT 2')); + + // Server would send at most [cap] rows after LIMIT injection. + // Simulate a producer that still has leftover rows (no LIMIT / SHOW). + final leftover = StreamController(); + final capped = takeThenDrain(leftover.stream, cap); + leftover + ..add(1) + ..add(2) + ..add(3); + await leftover.close(); + final taken = await capped; + expect(taken.items.length, cap); + + final followUp = await takeThenDrain( + Stream.fromIterable([1]), + cap, + ); + expect(followUp.items, [1]); + }); + }); + + group('live MySQL COM_QUERY drain', () { + test( + 'SELECT of N+1 rows with cap N; following SELECT 1 succeeds on same connection', + () async { + final port = + int.tryParse(Platform.environment['MYSQL_PORT'] ?? '') ?? 3306; + try { + final probe = await Socket.connect( + '127.0.0.1', + port, + timeout: const Duration(milliseconds: 200), + ); + await probe.close(); + } catch (_) { + markTestSkipped('MySQL is not listening on 127.0.0.1:$port'); + return; + } + + final conn = MysqlConnection( + id: 0, + name: 'live-808', + host: '127.0.0.1', + port: port, + username: Platform.environment['MYSQL_USER'] ?? 'querya', + password: Platform.environment['MYSQL_PASSWORD'] ?? 'querya', + database: Platform.environment['MYSQL_DATABASE'] ?? 'querya', + useSSL: false, + ); + await conn.connect(connectTimeoutMs: 3000); + addTearDown(() => conn.disconnect()); + + const cap = 2; + // Unbounded SELECT (no injectSqlLimit) so leftover protocol rows exist. + final rs = await conn.execute( + 'SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3', + null, + true, + ); + final taken = await takeThenDrain(rs.rowsStream, cap); + expect(taken.items.length, cap); + expect(taken.truncated, isTrue); + + final one = await conn.execute('SELECT 1 AS x'); + expect(one.rows.first.colAt(0), '1'); + }, + ); + }); +} diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index 65d662cd..1779d149 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -35,6 +35,24 @@ void main() { expect(restored.columns.length, 2); expect(restored.getColumn('id')?.isPrimaryKey, isTrue); expect(restored.getColumn('email')?.isNullable, isTrue); + expect(restored.getColumn('id')?.omitOnInsert, isFalse); + }); + + test('round-trips omitOnInsert and hasServerDefault', () { + const meta = TableSchemaMeta( + tableName: 'users', + columns: [ + TableColumnMeta( + name: 'id', + dataType: 'integer', + omitOnInsert: true, + hasServerDefault: true, + ), + ], + ); + final restored = TableSchemaMeta.fromJson(meta.toJson()); + expect(restored.getColumn('id')?.omitOnInsert, isTrue); + expect(restored.getColumn('id')?.hasServerDefault, isTrue); }); }); @@ -45,7 +63,9 @@ void main() { ['2', 'bob', '25', 'false'], ]; - test('generates UPDATE statement for modified cells with single PK in Postgres dialect', () { + test( + 'generates UPDATE statement for modified cells with single PK in Postgres dialect', + () { final plan = TableMutationEngine.generatePlan( dialect: SqlDialect.postgres, tableName: 'users', @@ -198,7 +218,9 @@ void main() { ); }); - test('preserves leading zeros and boolean strings in string columns with columnDataTypes', () { + test( + 'preserves leading zeros and boolean strings in string columns with columnDataTypes', + () { const stringCols = ['id', 'zip_code', 'is_flag_str']; const stringRows = [ ['1', '01234', 'true'], @@ -235,7 +257,9 @@ void main() { ); }); - test('preserves leading zeros in fallback heuristic without columnDataTypes', () { + test( + 'preserves leading zeros in fallback heuristic without columnDataTypes', + () { expect( TableMutationEngine.formatLiteral('01234', SqlDialect.postgres), '\'01234\'', @@ -313,6 +337,16 @@ void main() { "X'CAFE'", ); + // MySQL BOOLEAN stored as tinyint(1) + expect( + TableMutationEngine.formatLiteral( + '1', + SqlDialect.mysql, + dataTypeName: 'tinyint(1)', + ), + 'TRUE', + ); + // NULL for binary expect( TableMutationEngine.formatLiteral( @@ -352,5 +386,156 @@ void main() { r"'C:\Program Files\'", ); }); + + test('omits GENERATED ALWAYS identity column on Postgres INSERT', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + columns: ['id', 'email'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + ['', 'ada@example.com'], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'integer', + isNullable: false, + isPrimaryKey: true, + omitOnInsert: true, + hasServerDefault: true, + ), + 'email': const TableColumnMeta( + name: 'email', + dataType: 'varchar', + isNullable: false, + ), + }, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.first.sql, + 'INSERT INTO "public"."users" ("email") VALUES (\'ada@example.com\')', + ); + }); + + test('omits AUTO_INCREMENT id on MySQL INSERT', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.mysql, + tableName: 'users', + schema: 'app', + columns: ['id', 'name'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + ['', 'bob'], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'int', + isNullable: false, + isPrimaryKey: true, + omitOnInsert: true, + hasServerDefault: true, + ), + 'name': const TableColumnMeta( + name: 'name', + dataType: 'varchar', + isNullable: false, + ), + }, + ); + + expect( + plan.statements.first.sql, + 'INSERT INTO `app`.`users` (`name`) VALUES (\'bob\')', + ); + }); + + test('omits empty serial cell when hasServerDefault', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'items', + columns: ['id', 'label'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + ['', 'x'], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'integer', + isNullable: false, + isPrimaryKey: true, + hasServerDefault: true, + ), + 'label': const TableColumnMeta( + name: 'label', + dataType: 'text', + isNullable: false, + ), + }, + ); + + expect( + plan.statements.first.sql, + 'INSERT INTO "items" ("label") VALUES (\'x\')', + ); + }); + + test('DEFAULT VALUES when every INSERT column is omitted', () { + final pg = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 't', + columns: ['id'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + [''], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'integer', + omitOnInsert: true, + ), + }, + ); + expect(pg.statements.first.sql, 'INSERT INTO "t" DEFAULT VALUES'); + + final mysql = TableMutationEngine.generatePlan( + dialect: SqlDialect.mysql, + tableName: 't', + columns: ['id'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + [''], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'int', + omitOnInsert: true, + ), + }, + ); + expect(mysql.statements.first.sql, 'INSERT INTO `t` () VALUES ()'); + }); }); } diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 7cf70e6c..b095f0f4 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -125,6 +125,28 @@ void main() { await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); }); + test('getSqliteSqlStmtTimeoutSeconds roundtrip', () async { + expect( + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), isNull); + + await AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(60); + expect(await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), 60); + + await AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(null); + expect( + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), isNull); + }); + + test('getSqliteSqlStmtTimeoutSeconds returns null for invalid stored value', + () async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + 'not-a-number', + ); + expect( + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), isNull); + }); + test('getSqlResultMaxRows defaults and normalizes to preset', () async { expect(await AppSettings.instance.getSqlResultMaxRows(), kDefaultSqlResultMaxRows); diff --git a/test/core/unsaved_work_guard_test.dart b/test/core/unsaved_work_guard_test.dart new file mode 100644 index 00000000..c8a58b79 --- /dev/null +++ b/test/core/unsaved_work_guard_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + tearDown(UnsavedWorkRegistry.instance.resetForTest); + + testWidgets('returns true immediately when nothing is unsaved', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + expect(await confirmDiscardUnsavedWorkIfNeeded(ctx), isTrue); + }); + + testWidgets( + 'dirty buffer + Home/Close does not dispose until Discard is confirmed', + (tester) async { + final buffer = DataGridStagingBuffer( + columns: const ['id'], + rows: const [ + ['1'], + ], + ); + buffer.setCell(0, 0, '2'); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + var navigated = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) { + return; + } + buffer.dispose(); + navigated = true; + }, + child: const material.Text('Home'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + expect(navigated, isFalse); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + expect(find.text('Discard'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(navigated, isFalse); + expect(buffer.isDirty, isTrue); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(navigated, isTrue); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + }, + ); +} diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart index 49c9905f..c9d69d72 100644 --- a/test/features/connections/connection_url_parser_test.dart +++ b/test/features/connections/connection_url_parser_test.dart @@ -34,7 +34,8 @@ void main() { expect(row.username, 'alice'); expect(row.password, 'secret'); expect(row.databaseName, 'myapp'); - expect(row.connectionString, 'postgresql://alice:secret@db.example.com:5432/myapp'); + expect(row.connectionString, + 'postgresql://alice:secret@db.example.com:5432/myapp'); expect(row.useSSL, false); }); @@ -149,13 +150,17 @@ void main() { }); test('parses redis URL', () { - final result = parseConnectionUrlInput('redis://:password@localhost:6379'); + final result = + parseConnectionUrlInput('redis://:password@localhost:6379'); expect(result.error, isNull); final row = result.row!; expect(row.type, 'redis'); expect(row.name, 'Redis: localhost:6379'); expect(row.password, 'password'); - expect(row.connectionString, isNull); + expect( + row.connectionString, + 'redis://:password@localhost:6379', + ); }); test('uses default driver port when URI omits port', () { @@ -171,6 +176,19 @@ void main() { expect(result.row!.type, 'redis'); expect(result.row!.useSSL, true); expect(result.row!.port, 6379); + expect(result.row!.connectionString, 'rediss://localhost'); + }); + + test('keeps Redis TLS cert query params on the stored URI', () { + const url = 'rediss://cache.example.com:6380?sslrootcert=/ca.pem'; + final result = parseConnectionUrlInput(url); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'redis'); + expect(row.useSSL, true); + expect(row.host, 'cache.example.com'); + expect(row.port, 6380); + expect(row.connectionString, url); }); test('password with colon is preserved', () { diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart index 85f9ea7b..1cb72c26 100644 --- a/test/features/mongodb/mongo_document_editor_test.dart +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_editor.dart'; import '../../support/pump_syntax_highlight.dart'; @@ -15,12 +16,15 @@ void main() { await SyntaxHighlightService.ensureInitialized(); }); + tearDown(UnsavedWorkRegistry.instance.resetForTest); + final connection = MongoConnection(id: 1, name: 'test', host: 'localhost'); Future pumpEditor( WidgetTester tester, { QueryaTheme? theme, Map document = const {'_id': 'abc', 'a': 1}, + material.VoidCallback? onBack, }) async { await tester.pumpWidget( queryaThemeTestShell( @@ -33,6 +37,7 @@ void main() { database: 'db', collection: 'items', document: document, + onBack: onBack, ), ), ), @@ -91,4 +96,81 @@ void main() { ); expect(container.color, bg); }); + + testWidgets('Delete Cancel does not call onDocumentDeleted', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + var deleted = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 800, + height: 700, + child: MongoDocumentEditor( + connection: connection, + database: 'db', + collection: 'items', + document: const {'_id': 'abc', 'a': 1}, + onDocumentDeleted: () => deleted = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(find.text('DELETE DOCUMENT'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(deleted, isFalse); + expect(find.text('Delete'), findsOneWidget); + }); + + testWidgets('clean Back leaves the editor without a confirm dialog', + (tester) async { + var wentBack = false; + await pumpEditor(tester, onBack: () => wentBack = true); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + + expect(wentBack, isTrue); + expect(find.text('Unsaved changes'), findsNothing); + }); + + testWidgets('dirty Back Cancel keeps edits; Discard calls onBack', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + var wentBack = false; + await pumpEditor(tester, onBack: () => wentBack = true); + + await tester.enterText(find.byType(material.EditableText), '{"a":2}'); + await tester.pump(); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + expect(find.text('Unsaved changes'), findsOneWidget); + expect(wentBack, isFalse); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(wentBack, isFalse); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(wentBack, isTrue); + + await tester.pumpWidget( + queryaThemeTestShell(child: const material.SizedBox()), + ); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + }); } diff --git a/test/features/mongodb/mongo_ejson_test.dart b/test/features/mongodb/mongo_ejson_test.dart new file mode 100644 index 00000000..2bca49bc --- /dev/null +++ b/test/features/mongodb/mongo_ejson_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; + +void main() { + group('mongoDocumentToEjson / mongoDocumentFromEjson', () { + test('round-trips ObjectId _id and DateTime', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final created = DateTime.utc(2024, 1, 15, 12, 30, 0); + final doc = { + '_id': id, + 'created': created, + 'name': 'Ada', + }; + + final json = mongoDocumentToEjson(doc); + expect(json, contains(r'$oid')); + expect(json, contains('507f1f77bcf86cd799439011')); + expect(json, contains(r'$date')); + expect(json, isNot(contains('ObjectId('))); + + final back = mongoDocumentFromEjson(json); + expect(back['_id'], isA()); + expect((back['_id'] as ObjectId).oid, id.oid); + expect(back['created'], isA()); + expect( + (back['created'] as DateTime).toUtc(), + created, + ); + expect(back['name'], 'Ada'); + }); + + test('_id filter value keeps BSON ObjectId, not a string', () { + final id = ObjectId.fromHexString('507f191e810c19729de860ea'); + final json = mongoDocumentToEjson({'_id': id, 'n': 1}); + final back = mongoDocumentFromEjson(json); + expect(back['_id'], isA()); + expect(back['_id'], isNot(isA())); + }); + + test('relaxed EJSON keeps plain numbers and strings', () { + final json = mongoDocumentToEjson({'a': 1, 'b': 'x'}); + expect(json, contains('"a"')); + expect(json, contains('1')); + expect(json, isNot(contains(r'$numberInt'))); + final back = mongoDocumentFromEjson(json); + expect(back['a'], 1); + expect(back['b'], 'x'); + }); + + test('rejects a JSON array', () { + expect( + () => mongoDocumentFromEjson('[1, 2]'), + throwsA(isA()), + ); + }); + }); + + group('mongoFilterFromJson', () { + test('24-char hex _id becomes ObjectId, not a String', () { + const hex = '507f1f77bcf86cd799439011'; + final filter = mongoFilterFromJson('{"_id": "$hex"}'); + expect(filter['_id'], isA()); + expect(filter['_id'], isNot(isA())); + expect((filter['_id'] as ObjectId).oid, hex); + expect(mongoFilterNeedsObjectIdHint(filter), isFalse); + }); + + test('Extended JSON \$oid _id is ObjectId', () { + const hex = '507f191e810c19729de860ea'; + final filter = mongoFilterFromJson( + '{"_id": {"\$oid": "$hex"}}', + ); + expect(filter['_id'], isA()); + expect((filter['_id'] as ObjectId).oid, hex); + }); + + test('Extended JSON \$date is DateTime', () { + final filter = mongoFilterFromJson( + '{"created": {"\$date": "2024-01-15T12:30:00.000Z"}}', + ); + expect(filter['created'], isA()); + expect( + (filter['created'] as DateTime).toUtc(), + DateTime.utc(2024, 1, 15, 12, 30), + ); + }); + + test('non-hex _id string stays a string and needs the ObjectId hint', () { + final filter = mongoFilterFromJson('{"_id": "not-an-objectid"}'); + expect(filter['_id'], 'not-an-objectid'); + expect(mongoFilterNeedsObjectIdHint(filter), isTrue); + }); + + test('query operators like \$gt are kept', () { + final filter = mongoFilterFromJson('{"age": {"\$gt": 5}}'); + expect(filter['age'], isA()); + expect((filter['age'] as Map)[r'$gt'], 5); + }); + }); + + group('mongoFullDocumentReplacement', () { + test('start {a:1,b:2}, save {a:1} drops b and keeps original _id', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final replacement = mongoFullDocumentReplacement( + parsed: {'a': 1}, + originalId: id, + ); + expect(replacement.containsKey('b'), isFalse); + expect(replacement['a'], 1); + expect(replacement['_id'], same(id)); + }); + + test('nested keys deleted in JSON are absent from the replacement', () { + final id = ObjectId.fromHexString('507f191e810c19729de860ea'); + final replacement = mongoFullDocumentReplacement( + parsed: { + 'nested': {'x': 1}, + }, + originalId: id, + ); + expect(replacement['nested'], {'x': 1}); + expect((replacement['nested'] as Map).containsKey('y'), isFalse); + }); + + test('edited _id in JSON is overwritten with the original BSON id', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final replacement = mongoFullDocumentReplacement( + parsed: {'_id': 'tampered', 'a': 1}, + originalId: id, + ); + expect(replacement['_id'], same(id)); + expect(replacement['a'], 1); + }); + }); +} diff --git a/test/features/mongodb/mongo_field_codec_test.dart b/test/features/mongodb/mongo_field_codec_test.dart index fed3b167..3fd2114a 100644 --- a/test/features/mongodb/mongo_field_codec_test.dart +++ b/test/features/mongodb/mongo_field_codec_test.dart @@ -1,4 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; void main() { @@ -14,18 +16,100 @@ void main() { ); expect(mongoFieldToDisplay([1, 2]), '[\n 1,\n 2\n]'); }); + + test('ObjectId is hex, not ObjectId("…")', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + expect(mongoFieldToDisplay(id), '507f1f77bcf86cd799439011'); + expect(mongoFieldToDisplay(id), isNot(contains('ObjectId('))); + }); + + test('DateTime is ISO-8601 UTC', () { + final dt = DateTime.utc(2024, 6, 1, 12, 0, 0); + expect(mongoFieldToDisplay(dt), '2024-06-01T12:00:00.000Z'); + }); }); group('mongoDisplayToValue', () { - test('round-trips inspector strings', () { + test('round-trips inspector strings without an original type', () { expect(mongoDisplayToValue('NULL'), isNull); expect(mongoDisplayToValue('true'), isTrue); expect(mongoDisplayToValue('false'), isFalse); - expect(mongoDisplayToValue('3.5'), 3.5); expect(mongoDisplayToValue('Ada'), 'Ada'); expect(mongoDisplayToValue('{"ok":true}'), {'ok': true}); expect(mongoDisplayToValue('[1,2]'), [1, 2]); expect(mongoDisplayToValue('{not json'), '{not json'); }); + + test('numeric-looking strings stay strings', () { + expect(mongoDisplayToValue('42', original: '42'), '42'); + expect(mongoDisplayToValue('42', original: '42'), isA()); + expect(mongoDisplayToValue('02115', original: '02115'), '02115'); + expect(mongoDisplayToValue('3.5'), isA()); + }); + + test('int and double keep their width', () { + expect(mongoDisplayToValue('42', original: 42), 42); + expect(mongoDisplayToValue('42', original: 42), isA()); + expect(mongoDisplayToValue('3.5', original: 3.5), 3.5); + expect(mongoDisplayToValue('3.5', original: 3.5), isA()); + }); + + test('ObjectId round-trips from hex, shell literal, and \$oid', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + expect(mongoDisplayToValue(mongoFieldToDisplay(id), original: id), id); + expect( + mongoDisplayToValue('ObjectId("507f1f77bcf86cd799439011")', + original: id), + id, + ); + expect( + mongoDisplayToValue( + '{"\$oid":"507f1f77bcf86cd799439011"}', + original: id, + ), + id, + ); + }); + + test('DateTime round-trips from ISO-8601', () { + final dt = DateTime.utc(2024, 6, 1, 12, 0, 0); + final back = mongoDisplayToValue( + mongoFieldToDisplay(dt), + original: dt, + ); + expect(back, isA()); + expect((back as DateTime).toUtc(), dt); + }); + + test('Decimal128 round-trips', () { + final original = mongoDocumentFromEjson( + '{"v": {"\$numberDecimal": "10.50"}}', + )['v']; + expect(original, isNotNull); + final display = mongoFieldToDisplay(original); + final back = mongoDisplayToValue(display, original: original); + expect(back.runtimeType, original.runtimeType); + expect('$back', '$original'); + }); + + test('Int64 round-trips', () { + final original = mongoDocumentFromEjson( + '{"v": {"\$numberLong": "9007199254740993"}}', + )['v']; + expect(original, isNotNull); + final display = mongoFieldToDisplay(original); + final back = mongoDisplayToValue(display, original: original); + expect(back.runtimeType, original.runtimeType); + expect('$back', '$original'); + }); + }); + + group('mongoFieldIsReadOnly', () { + test('blocks _id', () { + expect(mongoFieldIsReadOnly('_id'), isTrue); + expect(mongoFieldIsReadOnly('name'), isFalse); + expect(() => mongoAssertFieldEditable('_id'), throwsStateError); + expect(() => mongoAssertFieldEditable('name'), returnsNormally); + }); }); } diff --git a/test/features/mysql/mysql_sql_tx_guard_test.dart b/test/features/mysql/mysql_sql_tx_guard_test.dart new file mode 100644 index 00000000..7a7b0590 --- /dev/null +++ b/test/features/mysql/mysql_sql_tx_guard_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + test('mysqlObjectOpensTableBrowser covers table and view only', () { + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.table), isTrue); + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.view), isTrue); + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.procedure), isFalse); + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.function), isFalse); + }); + + test('mysqlSqlToolbarTxLabel matches Postgres wording', () { + expect(mysqlSqlToolbarTxLabel(null), 'Transaction: —'); + expect(mysqlSqlToolbarTxLabel(true), 'Transaction: open'); + expect(mysqlSqlToolbarTxLabel(false), 'Transaction: none'); + }); + + testWidgets('Stay keeps the caller on the SQL session', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenMysqlTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Open transaction'), findsOneWidget); + + await tester.tap(find.text('Stay')); + await tester.pumpAndSettle(); + expect(left, isFalse); + }); +} diff --git a/test/features/mysql/mysql_table_utils_test.dart b/test/features/mysql/mysql_table_utils_test.dart index ab36baa3..b80f5442 100644 --- a/test/features/mysql/mysql_table_utils_test.dart +++ b/test/features/mysql/mysql_table_utils_test.dart @@ -84,4 +84,42 @@ void main() { expect(isAllowedMysqlSelectQuery('DELETE FROM t'), isFalse); }); }); + + group('mysqlBrowseDataSql', () { + test('orders by quoted PK columns', () { + expect( + mysqlBrowseDataSql( + qualifiedFrom: '`shop`.`orders`', + primaryKeys: const ['id'], + limit: 200, + offset: 400, + ), + 'SELECT * FROM `shop`.`orders` ORDER BY `id` LIMIT 200 OFFSET 400', + ); + }); + + test('composite PK lists all columns', () { + expect( + mysqlBrowseDataSql( + qualifiedFrom: '`db`.`t`', + primaryKeys: const ['a', 'b'], + limit: 200, + offset: 0, + ), + 'SELECT * FROM `db`.`t` ORDER BY `a`, `b` LIMIT 200 OFFSET 0', + ); + }); + + test('omits ORDER BY when there is no PK', () { + expect( + mysqlBrowseDataSql( + qualifiedFrom: '`db`.`v`', + primaryKeys: const [], + limit: 200, + offset: 0, + ), + 'SELECT * FROM `db`.`v` LIMIT 200 OFFSET 0', + ); + }); + }); } diff --git a/test/features/postgresql/postgres_sql_tx_guard_test.dart b/test/features/postgresql/postgres_sql_tx_guard_test.dart new file mode 100644 index 00000000..07ef0b4d --- /dev/null +++ b/test/features/postgresql/postgres_sql_tx_guard_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + test('postgresObjectOpensTableBrowser covers grid kinds only', () { + expect(postgresObjectOpensTableBrowser(PostgresObjectKind.table), isTrue); + expect(postgresObjectOpensTableBrowser(PostgresObjectKind.view), isTrue); + expect( + postgresObjectOpensTableBrowser(PostgresObjectKind.materializedView), + isTrue, + ); + expect( + postgresObjectOpensTableBrowser(PostgresObjectKind.function), isFalse); + expect( + postgresObjectOpensTableBrowser(PostgresObjectKind.sequence), isFalse); + }); + + testWidgets('Stay keeps the caller on the SQL session', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenPostgresTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Open transaction'), findsOneWidget); + + await tester.tap(find.text('Stay')); + await tester.pumpAndSettle(); + expect(left, isFalse); + }); + + testWidgets('Leave confirms abandoning the open transaction', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenPostgresTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Leave')); + await tester.pumpAndSettle(); + expect(left, isTrue); + }); +} diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart index 8b59e7e8..db70c9b9 100644 --- a/test/features/postgresql/postgres_table_utils_test.dart +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -23,6 +23,64 @@ void main() { }); }); + group('isAllowedPostgresSelectQuery', () { + test('allows SELECT inserted_at (not substring-blocked)', () { + expect( + isAllowedPostgresSelectQuery('SELECT inserted_at FROM t'), + isTrue, + ); + expect( + isAllowedPostgresSelectQuery('SELECT dropped_at, updated_at FROM t'), + isTrue, + ); + }); + + test('rejects multi-statement even when the first is SELECT', () { + expect(isAllowedPostgresSelectQuery('SELECT 1; DELETE FROM t'), isFalse); + expect(isAllowedPostgresSelectQuery('SELECT 1; SELECT 2'), isFalse); + }); + + test('allows trailing semicolon and trailing comment', () { + expect(isAllowedPostgresSelectQuery('SELECT * FROM t;'), isTrue); + expect(isAllowedPostgresSelectQuery('SELECT * FROM t; -- done'), isTrue); + }); + + test('allows WITH SELECT, TABLE, VALUES, and parenthesized SELECT', () { + expect( + isAllowedPostgresSelectQuery('WITH x AS (SELECT 1) SELECT * FROM x'), + isTrue, + ); + expect(isAllowedPostgresSelectQuery('TABLE t'), isTrue); + expect(isAllowedPostgresSelectQuery('VALUES (1)'), isTrue); + expect( + isAllowedPostgresSelectQuery('(SELECT inserted_at FROM t)'), isTrue); + }); + + test('rejects WITH INSERT and other writes', () { + expect( + isAllowedPostgresSelectQuery( + 'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x', + ), + isFalse, + ); + expect(isAllowedPostgresSelectQuery('DELETE FROM t'), isFalse); + expect(isAllowedPostgresSelectQuery('INSERT INTO t VALUES (1)'), isFalse); + }); + + test('allows semicolon inside a string literal', () { + expect( + isAllowedPostgresSelectQuery( + "SELECT * FROM logs WHERE message = 'error; system halted'", + ), + isTrue, + ); + }); + + test('rejects empty', () { + expect(isAllowedPostgresSelectQuery(''), isFalse); + }); + }); + group('postgresBrowseSelectSql', () { test('quotes identifiers and uses default limit', () { expect( @@ -32,6 +90,44 @@ void main() { }); }); + group('postgresBrowseDataSql', () { + test('orders by quoted PK columns', () { + expect( + postgresBrowseDataSql( + qualifiedFrom: '"public"."orders"', + primaryKeys: const ['id'], + limit: 200, + offset: 400, + ), + 'SELECT * FROM "public"."orders" ORDER BY "id" LIMIT 200 OFFSET 400', + ); + }); + + test('composite PK lists all columns', () { + expect( + postgresBrowseDataSql( + qualifiedFrom: '"public"."t"', + primaryKeys: const ['a', 'b'], + limit: 200, + offset: 0, + ), + 'SELECT * FROM "public"."t" ORDER BY "a", "b" LIMIT 200 OFFSET 0', + ); + }); + + test('omits ORDER BY when there is no PK', () { + expect( + postgresBrowseDataSql( + qualifiedFrom: '"public"."v"', + primaryKeys: const [], + limit: 200, + offset: 0, + ), + 'SELECT * FROM "public"."v" LIMIT 200 OFFSET 0', + ); + }); + }); + group('convertResultRowsToStrings', () { test('converts null to "NULL"', () { final result = convertResultRowsToStrings([ diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index a3461d88..48a81b7a 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -300,6 +300,7 @@ void main() { expect(result!.connectionString, contains('pg.example.com')); expect(result!.connectionString, contains('sslrootcert')); expect(result!.connectionString, contains('root.pem')); + expect(result!.connectionString, contains('sslmode=verify-full')); expect(result!.connectionString, contains('admin')); expect(result!.connectionString, contains('secret')); expect(result!.useSSL, true); diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart new file mode 100644 index 00000000..9bc57880 --- /dev/null +++ b/test/features/redis/redis_key_editor_test.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/features/redis/redis_key_editor.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future pumpEditor( + WidgetTester tester, { + required RedisConnectionTestFake fake, + required bool isReadOnly, + material.VoidCallback? onKeyDeleted, + material.Size size = const material.Size(800, 600), + String keyType = 'string', + String keyName = 'session:1', + }) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: size.width, + height: size.height, + child: RedisKeyEditor( + connection: fake, + database: 0, + keyName: keyName, + keyType: keyType, + isReadOnly: isReadOnly, + onKeyDeleted: onKeyDeleted, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('RedisKeyEditor hides Save, TTL, and delete when locked', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: true); + + expect(find.text('hello'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.byTooltip('Set TTL'), findsNothing); + expect(find.byTooltip('Delete key'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor shows Save when the session is writable', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: false); + + expect(find.text('Save'), findsOneWidget); + expect(find.byTooltip('Set TTL'), findsOneWidget); + expect(find.byTooltip('Delete key'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor Delete Cancel does not call onKeyDeleted', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + var deleted = false; + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + onKeyDeleted: () => deleted = true, + size: const material.Size(800, 700), + ); + + await tester.tap(find.byTooltip('Delete key')); + await tester.pumpAndSettle(); + + expect(find.text('DEL session:1'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(deleted, isFalse); + expect(find.byTooltip('Delete key'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor list loads a page instead of LRANGE 0 -1', + (tester) async { + final fake = RedisConnectionTestFake( + listItems: List.generate(250, (i) => 'item_$i'), + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: true, + keyType: 'list', + keyName: 'jobs', + size: const material.Size(800, 700), + ); + + expect(find.text('List items (200 / 250)'), findsOneWidget); + expect(find.text('Load more (200 / 250)'), findsOneWidget); + expect(find.text('item_0'), findsOneWidget); + expect(find.text('item_249'), findsNothing); + + await tester.tap(find.text('Load more (200 / 250)')); + await tester.pumpAndSettle(); + + expect(find.text('List items (250 / 250)'), findsOneWidget); + expect(find.text('Load more (200 / 250)'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor hash uses HSCAN and warns when huge', + (tester) async { + final fake = RedisConnectionTestFake( + hashFirstPage: const {'email': 'a@b.c'}, + hashSecondPage: const {'name': 'Ada'}, + hlenResult: 15000, + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: true, + keyType: 'hash', + keyName: 'user:1', + size: const material.Size(800, 700), + ); + + expect( + find.textContaining('Large key (15000 members)'), + findsOneWidget, + ); + expect(find.text('Hash fields (1 / 15000)'), findsOneWidget); + expect(find.text('email'), findsOneWidget); + expect(find.text('name'), findsNothing); + + await tester.tap(find.text('Load more (1 / 15000)')); + await tester.pumpAndSettle(); + expect(find.text('name'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor stream does not GET or show Save', + (tester) async { + final fake = RedisConnectionTestFake( + getResult: 'should-not-load', + typeResult: 'stream', + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'stream', + keyName: 'querya:stream:events', + ); + + expect(find.text('Unsupported type'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.text('should-not-load'), findsNothing); + expect(fake.sentCommands.contains('GET'), isFalse); + expect(fake.sentCommands.contains('SET'), isFalse); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor unknown does not GET until TYPE succeeds', + (tester) async { + final fake = RedisConnectionTestFake( + getResult: 'should-not-load', + failType: true, + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'unknown', + keyName: 'maybe-module', + ); + + expect(find.text('Type unknown'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.text('should-not-load'), findsNothing); + expect(fake.sentCommands.contains('GET'), isFalse); + expect(fake.sentCommands.contains('SET'), isFalse); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor unknown TYPE stream does not GET', + (tester) async { + final fake = RedisConnectionTestFake( + getResult: 'should-not-load', + typeResult: 'stream', + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'unknown', + keyName: 'querya:stream:events', + ); + + expect(find.text('Unsupported type'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.textContaining('STREAM'), findsWidgets); + expect(fake.sentCommands.contains('TYPE'), isTrue); + expect(fake.sentCommands.contains('GET'), isFalse); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor unknown TYPE string then GET and Save', + (tester) async { + final fake = + RedisConnectionTestFake(getResult: 'hello', typeResult: 'string'); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'unknown', + keyName: 'session:1', + ); + + expect(find.text('hello'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + expect(fake.sentCommands.contains('TYPE'), isTrue); + expect(fake.sentCommands.contains('GET'), isTrue); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor binary GET shows hex and hides Save', + (tester) async { + final fake = RedisConnectionTestFake( + getBytesResult: const [0xff, 0xfe, 0x01], + ); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: false); + + expect(find.textContaining('Binary value'), findsOneWidget); + expect(find.text('fffe01'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.text('[255, 254, 1]'), findsNothing); + await fake.disconnect(); + }); +} diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index b888f931..146da411 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/redis/redis_keys_view.dart'; @@ -66,4 +67,148 @@ void main() { expect(find.text('No keys found'), findsOneWidget); await fake.disconnect(); }); + + testWidgets( + 'RedisKeysView hides delete and shows Read-only session when locked', + (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['key_a'], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + isReadOnly: true, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('key_a'), findsOneWidget); + expect(find.text('Read-only session'), findsOneWidget); + expect(find.byTooltip('Delete key'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeysView shows delete when the session is writable', + (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['key_a'], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byTooltip('Delete key'), findsOneWidget); + expect(find.text('Read-only session'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeysView Delete Cancel does not send DEL', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + final fake = _DelTrackingFake( + firstScanKeys: const ['key_a'], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 700, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Delete key')); + await tester.pumpAndSettle(); + + expect(find.text('DEL key_a'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(fake.deleted, isEmpty); + expect(find.text('key_a'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets('RedisKeysView shows binary SCAN keys as hex, not List.toString', + (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['ascii'], + binaryScanKeys: const [ + [0xff, 0xfe], + ], + dbSizeResult: 2, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('ascii'), findsOneWidget); + expect(find.text('0xfffe'), findsOneWidget); + expect(find.text('[255, 254]'), findsNothing); + await fake.disconnect(); + }); +} + +class _DelTrackingFake extends RedisConnectionTestFake { + _DelTrackingFake({super.firstScanKeys, super.dbSizeResult}); + + final deleted = []; + + @override + Future del(Object key) async { + deleted.add(key is RedisBulkValue ? key.label : key.toString()); + return super.del(key); + } } diff --git a/test/features/sql_workspaces/result_conversion_perf_test.dart b/test/features/sql_workspaces/result_conversion_perf_test.dart index 902d9a8b..b82c87a3 100644 --- a/test/features/sql_workspaces/result_conversion_perf_test.dart +++ b/test/features/sql_workspaces/result_conversion_perf_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; @@ -31,17 +33,24 @@ void main() { expect(out[1], ['NULL', '999', 'foo']); }); - test('convertSqliteResultRowsToStrings maps nulls and primitives correctly', () { + test('convertSqliteResultRowsToStrings maps nulls, primitives, and blobs', + () { final rawRows = [ ['sqlite', null, 42], [null, null, null], + [ + Uint8List.fromList(const [0xff]), + 'x', + 1 + ], ]; final job = SqliteResultConvertJob(rowValues: rawRows); final out = convertSqliteResultRowsToStrings(job); - expect(out.length, 2); + expect(out.length, 3); expect(out[0], ['sqlite', 'NULL', '42']); expect(out[1], ['NULL', 'NULL', 'NULL']); + expect(out[2], ["X'ff'", 'x', '1']); }); test('All convert jobs handle large batches efficiently', () { diff --git a/test/features/sqlite/sqlite_grid_types_test.dart b/test/features/sqlite/sqlite_grid_types_test.dart new file mode 100644 index 00000000..673390fe --- /dev/null +++ b/test/features/sqlite/sqlite_grid_types_test.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; + +void main() { + group('sqliteResultCellToDisplayString', () { + test('BLOB bytes display as X\'hex\'', () { + expect( + sqliteResultCellToDisplayString( + Uint8List.fromList(const [0xde, 0xad, 0xbe, 0xef]), + ), + "X'deadbeef'", + ); + expect( + sqliteResultCellToDisplayString( + const [0x00, 0x00, 0x00, 0x00], + dataTypeName: 'BLOB', + ), + "X'00000000'", + ); + }); + + test('TEXT stays the string, including leading zeros', () { + expect( + sqliteResultCellToDisplayString('00123', dataTypeName: 'TEXT'), + '00123', + ); + expect(sqliteResultCellToDisplayString(null), 'NULL'); + }); + }); + + group('all_sqlite_types persist', () { + test('BLOB hex persists as X\'..\'', () { + expect( + TableMutationEngine.formatLiteral( + "X'deadbeefcafe0102030405'", + SqlDialect.sqlite, + dataTypeName: 'BLOB', + ), + "X'deadbeefcafe0102030405'", + ); + }); + + test('TEXT 00123 stays a quoted string, not an integer', () { + expect( + TableMutationEngine.formatLiteral( + '00123', + SqlDialect.sqlite, + dataTypeName: 'TEXT', + ), + "'00123'", + ); + expect( + TableMutationEngine.formatLiteral( + '123', + SqlDialect.sqlite, + dataTypeName: 'text', + ), + "'123'", + ); + }); + }); + + group('convertSqliteResultRowsToStrings', () { + test('maps blob bytes to hex, not List.toString', () { + final out = convertSqliteResultRowsToStrings( + SqliteResultConvertJob( + rowValues: [ + [ + 'ok', + Uint8List.fromList(const [0xca, 0xfe]), + null, + ], + ], + ), + ); + expect(out.single, ['ok', "X'cafe'", 'NULL']); + }); + }); +} diff --git a/test/features/sqlite/sqlite_sql_tx_guard_test.dart b/test/features/sqlite/sqlite_sql_tx_guard_test.dart new file mode 100644 index 00000000..7eb47fe3 --- /dev/null +++ b/test/features/sqlite/sqlite_sql_tx_guard_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + test('sqliteObjectOpensTableBrowser covers table and view', () { + expect(sqliteObjectOpensTableBrowser(SqliteObjectKind.table), isTrue); + expect(sqliteObjectOpensTableBrowser(SqliteObjectKind.view), isTrue); + }); + + testWidgets('Stay keeps the caller on the SQL session', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenSqliteTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Open transaction'), findsOneWidget); + + await tester.tap(find.text('Stay')); + await tester.pumpAndSettle(); + expect(left, isFalse); + }); +} diff --git a/test/features/sqlite/sqlite_table_utils_test.dart b/test/features/sqlite/sqlite_table_utils_test.dart new file mode 100644 index 00000000..80750f19 --- /dev/null +++ b/test/features/sqlite/sqlite_table_utils_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; + +void main() { + group('sqliteTableBrowserPrimaryKeys', () { + test('keeps a declared PRIMARY KEY', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const ['id'], + isView: false, + ), + ['id'], + ); + }); + + test('uses rowid when a table has no declared PK', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const [], + isView: false, + ), + ['rowid'], + ); + }); + + test('stays empty for views', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const [], + isView: true, + ), + isEmpty, + ); + }); + }); + + group('sqliteBrowseOrderColumns', () { + test('uses declared PK columns', () { + expect( + sqliteBrowseOrderColumns( + primaryKeys: const ['id'], + isView: false, + ), + ['id'], + ); + }); + + test('uses rowid when a table has no PK', () { + expect( + sqliteBrowseOrderColumns( + primaryKeys: const [], + isView: false, + ), + ['rowid'], + ); + }); + + test('omits order columns for views', () { + expect( + sqliteBrowseOrderColumns( + primaryKeys: const [], + isView: true, + ), + isEmpty, + ); + }); + }); + + group('sqliteBrowseDataSql', () { + test('orders by quoted PK columns', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"orders"', + primaryKeys: const ['id'], + isView: false, + limit: 200, + offset: 400, + ), + 'SELECT * FROM "orders" ORDER BY "id" LIMIT 200 OFFSET 400', + ); + }); + + test('composite PK lists all columns', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"t"', + primaryKeys: const ['a', 'b'], + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "t" ORDER BY "a", "b" LIMIT 200 OFFSET 0', + ); + }); + + test('projects rowid and orders by it when a table has no PK', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"t"', + primaryKeys: const [], + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT "rowid", * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', + ); + }); + + test('projects rowid when PK was resolved to implicit rowid', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"t"', + primaryKeys: const ['rowid'], + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT "rowid", * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', + ); + }); + + test('omits ORDER BY for views', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"v"', + primaryKeys: const [], + isView: true, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "v" LIMIT 200 OFFSET 0', + ); + }); + }); +} diff --git a/test/features/workspace/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart index 8e72fe0f..334533ef 100644 --- a/test/features/workspace/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_test.dart @@ -8,11 +8,14 @@ import '../../support/querya_theme_test_shell.dart'; void main() { group('DestructiveQueryDialog', () { - testWidgets('renders warning, detected operations, and disables confirm until acknowledged', (tester) async { + testWidgets( + 'renders warning, detected operations, and disables confirm until acknowledged', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); bool? result; - final inspection = DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'); + final inspection = + DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'); await tester.pumpWidget( queryaThemeTestShell( @@ -36,7 +39,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Destructive Operation Detected'), findsOneWidget); - expect(find.text('Target connection: Production PostgreSQL'), findsOneWidget); + expect(find.text('Target connection: Production PostgreSQL'), + findsOneWidget); expect(find.text('DROP TABLE'), findsOneWidget); expect(find.text('DROP TABLE legacy_users;'), findsOneWidget); expect(find.text('Execute Destructive Statement'), findsOneWidget); @@ -60,7 +64,8 @@ void main() { testWidgets('shows Critical header for DROP DATABASE', (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); - final inspection = DestructiveSqlDetector.inspect('DROP DATABASE customer_records;'); + final inspection = + DestructiveSqlDetector.inspect('DROP DATABASE customer_records;'); await tester.pumpWidget( queryaThemeTestShell( @@ -115,5 +120,160 @@ void main() { expect(result, isFalse); }); + + testWidgets( + 'Mongo drop database shows Critical and Cancel does not confirm', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = true; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropDatabase, + targetName: 'orders', + commandPreview: '{ dropDatabase: 1 }', + connectionName: 'prod-mongo', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Critical Destructive Operation'), findsOneWidget); + expect(find.text('DROP DATABASE'), findsOneWidget); + expect(find.text('{ dropDatabase: 1 }'), findsOneWidget); + expect(find.text('Target connection: prod-mongo'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + }); + + testWidgets( + 'Mongo drop collection confirm stays disabled until acknowledged', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropCollection, + targetName: 'users', + commandPreview: 'db.users.drop()', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('DROP COLLECTION'), findsOneWidget); + expect(find.text('db.users.drop()'), findsOneWidget); + + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + expect(find.text('DROP COLLECTION'), findsOneWidget); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isTrue); + }); + + testWidgets('Redis DEL Cancel does not confirm', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = true; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisDel, + targetName: 'session:1 (string)', + commandPreview: 'DEL session:1', + connectionName: 'cache', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('DEL'), findsWidgets); + expect(find.text('DEL session:1'), findsOneWidget); + expect(find.text('Target connection: cache'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + }); + + testWidgets('Redis HDEL confirm stays disabled until acknowledged', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisHdel, + targetName: 'email', + commandPreview: 'HDEL user:1 email', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('HDEL'), findsWidgets); + expect(find.text('HDEL user:1 email'), findsOneWidget); + + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isTrue); + }); }); } diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 2347d386..56f70abd 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -342,6 +342,87 @@ void main() { expect(result, 'Ada'); expect(find.text('Save to DB'), findsNothing); }); + + testWidgets('Apply persists through onSaveToDatabase after edit', + (tester) async { + String? saved; + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'name', + initialValue: 'Ada', + onSaveToDatabase: (value) async { + saved = value; + }, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(material.TextField), 'Bob'); + await tester.pumpAndSettle(); + + expect(find.text('Apply'), findsOneWidget); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(saved, 'Bob'); + expect(result, 'Bob'); + expect(find.text('Save to DB'), findsNothing); + }); + + testWidgets('Ctrl+Enter persists through onSaveToDatabase', (tester) async { + String? saved; + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'name', + initialValue: 'Ada', + onSaveToDatabase: (value) async { + saved = value; + }, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(material.TextField), 'Bob'); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(saved, 'Bob'); + expect(result, 'Bob'); + }); }); group('VirtualResultGrid Inline Editing Integration', () { diff --git a/test/features/workspace/grid_data_type_validator_test.dart b/test/features/workspace/grid_data_type_validator_test.dart index 9e5f051c..03004152 100644 --- a/test/features/workspace/grid_data_type_validator_test.dart +++ b/test/features/workspace/grid_data_type_validator_test.dart @@ -24,6 +24,14 @@ void main() { expect(GridDataTypeValidator.validate('1', dataTypeName: 'bool'), isNull); expect(GridDataTypeValidator.validate('0', dataTypeName: 'bool'), isNull); expect(GridDataTypeValidator.validate('yes', dataTypeName: 'bool'), isNotNull); + expect( + GridDataTypeValidator.validate('true', dataTypeName: 'tinyint(1)'), + isNull, + ); + expect( + GridDataTypeValidator.validate('yes', dataTypeName: 'tinyint(1)'), + isNotNull, + ); }); test('validates UUID types', () { @@ -80,10 +88,22 @@ void main() { expect(GridDataTypeValidator.validate('0x12AB', dataTypeName: 'blob'), isNull); expect(GridDataTypeValidator.validate("X'CAFE'", dataTypeName: 'binary'), isNull); expect(GridDataTypeValidator.validate('DEADBEEF', dataTypeName: 'varbinary'), isNull); + expect(GridDataTypeValidator.validate('0xaa', dataTypeName: 'bit(8)'), isNull); expect(GridDataTypeValidator.validate('not_hex', dataTypeName: 'blob'), isNotNull); 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); diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index 056eef8d..f7e868e0 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -947,6 +947,30 @@ void main() { expect(appliedChanges, 1); }); + testWidgets('hides Save Changes when staging is disabled (session lock)', + (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 800, + height: 500, + child: ResultsTab( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DataGridStagingToolbar), findsNothing); + expect(find.text('Save Changes'), findsNothing); + }); + testWidgets('memoizes filtered rows across rebuilds when filterText and rows do not change', (tester) async { final rows = [ ['1', 'Alice', 'Engineering'], diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index 58c436ca..c39ece84 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -54,6 +54,24 @@ void main() { ), isFalse, ); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + readOnly: true, + ), + isFalse, + ); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + schemaError: StateError('permission denied'), + ), + isFalse, + ); }); }); @@ -105,6 +123,16 @@ void main() { ), isNull, ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + schemaLoaded: true, + readOnly: true, + ), + 'Read-only session', + ); expect( tableViewEditDisabledReason( isView: false, @@ -114,6 +142,76 @@ void main() { ), isNull, ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: StateError('permission denied'), + ), + 'Cannot edit: schema unavailable. ' + 'Bad state: permission denied. Refresh to retry.', + ); + }); + + test('schema load failure is not reported as a missing PK', () { + final reason = tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: Exception('information_schema denied'), + ); + expect(reason, contains('schema unavailable')); + expect(reason, contains('information_schema denied')); + expect(reason, contains('Refresh to retry')); + expect(reason, isNot(contains('no primary key'))); + }); + }); + + group('loadTableViewSchema', () { + test('returns schema on success', () async { + const meta = TableSchemaMeta( + tableName: 'users', + primaryKeys: ['id'], + ); + final loaded = await loadTableViewSchema(() async => meta); + expect(loaded.isOk, isTrue); + expect(loaded.schema, same(meta)); + expect(loaded.error, isNull); + }); + + test('captures a throwing getTableSchema stub instead of empty PKs', + () async { + Future throwingStub() async { + throw StateError('permission denied'); + } + + final loaded = await loadTableViewSchema(throwingStub); + expect(loaded.isOk, isFalse); + expect(loaded.schema, isNull); + expect(loaded.error, isA()); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: loaded.schema?.hasPrimaryKey ?? false, + schemaLoaded: true, + schemaError: loaded.error, + ), + contains('schema unavailable'), + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: loaded.error, + ), + isNot(contains('no primary key')), + ); }); }); @@ -132,6 +230,20 @@ void main() { {'id': 'integer', 'name': 'text'}, ); }); + + test('columnMetaFromSchema indexes columns by name', () { + const schema = TableSchemaMeta( + tableName: 'users', + columns: [ + TableColumnMeta( + name: 'id', + dataType: 'integer', + omitOnInsert: true, + ), + ], + ); + expect(columnMetaFromSchema(schema)['id']?.omitOnInsert, isTrue); + }); }); group('replaceTableViewStagingBuffer', () { @@ -234,6 +346,62 @@ void main() { expect(outcome.error.toString(), contains('no primary key')); expect(executed, isFalse); }); + + testWidgets('0-row DML is failed and the staging buffer stays dirty', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Ada'], + ], + ); + buffer.setCell(0, 1, 'Grace'); + addTearDown(buffer.dispose); + + final future = applyTableViewStagedChanges( + context: ctx, + buffer: buffer, + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + primaryKeys: ['id'], + execute: (_) async => expectDmlMatchedRows(0), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply Changes')); + final outcome = await future; + + expect(outcome.isFailed, isTrue); + expect(outcome.error.toString(), contains('matched 0 rows')); + expect(buffer.isDirty, isTrue); + }); + }); + + group('expectDmlMatchedRows', () { + test('allows 1+ affected rows', () { + expect(() => expectDmlMatchedRows(1), returnsNormally); + expect(() => expectDmlMatchedRows(3), returnsNormally); + }); + + test('throws on 0-row DML so Save is a failure', () { + expect( + () => expectDmlMatchedRows(0), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('matched 0 rows'), + ), + ), + ); + }); }); group('TableBrowserPendingActions', () { diff --git a/third_party/mysql_client/lib/mysql_protocol_extension.dart b/third_party/mysql_client/lib/mysql_protocol_extension.dart index e8ed8f57..80e91a00 100644 --- a/third_party/mysql_client/lib/mysql_protocol_extension.dart +++ b/third_party/mysql_client/lib/mysql_protocol_extension.dart @@ -18,6 +18,14 @@ extension MySQLUint8ListExtension on Uint8List { } Tuple2 getUtf8LengthEncodedString(int startOffset) { + return getLengthEncodedString(startOffset); + } + + /// Length-encoded string. [latin1Bytes] maps each byte 1:1 (BIT/BLOB/BINARY). + Tuple2 getLengthEncodedString( + int startOffset, { + bool latin1Bytes = false, + }) { final tmp = Uint8List.sublistView(this, startOffset); final bd = ByteData.sublistView(tmp); @@ -29,7 +37,8 @@ extension MySQLUint8ListExtension on Uint8List { strLength.item2 + strLength.item1.toInt(), ); - return Tuple2(utf8.decode(tmp2), strLength.item2 + strLength.item1.toInt()); + final decoded = latin1Bytes ? latin1.decode(tmp2) : utf8.decode(tmp2); + return Tuple2(decoded, strLength.item2 + strLength.item1.toInt()); } } diff --git a/third_party/mysql_client/lib/src/mysql_client/connection.dart b/third_party/mysql_client/lib/src/mysql_client/connection.dart index 821b0f4c..4f04d583 100644 --- a/third_party/mysql_client/lib/src/mysql_client/connection.dart +++ b/third_party/mysql_client/lib/src/mysql_client/connection.dart @@ -33,6 +33,8 @@ class MySQLConnection { bool _inTransaction = false; final bool _secure; final SecurityContext? _securityContext; + final bool _sslVerifyCertificates; + final String? _sslServerName; final List _incompleteBufferData = []; Object? _lastError; int _serverCapabilities = 0; @@ -47,12 +49,16 @@ class MySQLConnection { bool secure = true, String? databaseName, SecurityContext? securityContext, + bool sslVerifyCertificates = false, + String? sslServerName, }) : _socket = socket, _username = username, _password = password, _databaseName = databaseName, _secure = secure, _securityContext = securityContext, + _sslVerifyCertificates = sslVerifyCertificates, + _sslServerName = sslServerName, _collation = collation; /// Creates connection with provided options. @@ -82,6 +88,8 @@ class MySQLConnection { String? databaseName, String collation = 'utf8mb4_general_ci', SecurityContext? securityContext, + bool sslVerifyCertificates = false, + String? sslServerName, }) async { final Socket socket = await Socket.connect(host, port); @@ -97,6 +105,8 @@ class MySQLConnection { databaseName: databaseName, secure: secure, securityContext: securityContext, + sslVerifyCertificates: sslVerifyCertificates, + sslServerName: sslServerName, collation: collation, ); @@ -364,8 +374,10 @@ class MySQLConnection { final secureSocket = await SecureSocket.secure( _socket, + host: _sslServerName, context: _securityContext, - onBadCertificate: (certificate) => true, + onBadCertificate: + _sslVerifyCertificates ? null : (certificate) => true, ); // switch socket @@ -550,7 +562,11 @@ class MySQLConnection { return; } - packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + packet = MySQLPacket.decodeResultSetRowPacket( + data, + colsCount, + colDefs, + ); final values = (packet.payload as MySQLResultSetRowPacket).values; sink!.add(ResultSetRow._(colDefs: colDefs, values: values)); packet = null; @@ -593,7 +609,11 @@ class MySQLConnection { } } - packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + packet = MySQLPacket.decodeResultSetRowPacket( + data, + colsCount, + colDefs, + ); break; } } @@ -1258,6 +1278,7 @@ class ResultSet extends IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1318,6 +1339,7 @@ class IterableResultSet with IterableMixin implements IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1365,6 +1387,7 @@ class PreparedStmtResultSet extends IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1412,6 +1435,7 @@ class IterablePreparedStmtResultSet extends IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1587,12 +1611,23 @@ class ResultSetColumn { String name; MySQLColumnType type; int length; + int charset; ResultSetColumn({ required this.name, required this.type, required this.length, + this.charset = 0, }); + + /// BIT / BLOB / BINARY payload (charset 63 or blob/bit type). + bool get isBinaryPayload => mysqlColumnHoldsRawBytes( + columnType: type.intVal, + charset: charset, + ); + + /// `TINYINT(1)` / BOOLEAN. + bool get isBooleanTiny => type.intVal == mysqlColumnTypeTiny && length == 1; } /// Prepared statement class diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart index 6b07e1d5..9fcae8c9 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart @@ -34,6 +34,29 @@ const mysqlColumnTypeVarString = 0xfd; const mysqlColumnTypeString = 0xfe; const mysqlColumnTypeGeometry = 0xff; +/// `character_set` id 63 (`binary`) — BIT / BLOB / BINARY / VARBINARY. +const mysqlCharsetBinary = 63; + +/// Whether the wire value is raw bytes (latin1 1:1), not UTF-8 text. +bool mysqlColumnHoldsRawBytes({ + required int columnType, + required int charset, +}) { + if (charset == mysqlCharsetBinary) { + return true; + } + switch (columnType) { + case mysqlColumnTypeBit: + case mysqlColumnTypeTinyBlob: + case mysqlColumnTypeMediumBlob: + case mysqlColumnTypeLongBlob: + case mysqlColumnTypeBlob: + return true; + default: + return false; + } +} + class MySQLColumnType { final int _value; @@ -188,8 +211,9 @@ Tuple2 parseBinaryColumnData( int columnType, ByteData data, Uint8List buffer, - int startOffset, -) { + int startOffset, [ + int charset = 0, +]) { switch (columnType) { case mysqlColumnTypeTiny: final value = data.getInt8(startOffset); @@ -333,7 +357,13 @@ Tuple2 parseBinaryColumnData( case mysqlColumnTypeBit: case mysqlColumnTypeDecimal: case mysqlColumnTypeNewDecimal: - return buffer.getUtf8LengthEncodedString(startOffset); + return buffer.getLengthEncodedString( + startOffset, + latin1Bytes: mysqlColumnHoldsRawBytes( + columnType: columnType, + charset: charset, + ), + ); } throw MySQLProtocolException( diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart index c24c6e9c..e5651f62 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart @@ -238,8 +238,9 @@ class MySQLPacket { factory MySQLPacket.decodeResultSetRowPacket( Uint8List buffer, - int numOfCols, - ) { + int numOfCols, [ + List? colDefs, + ]) { int offset = 0; final header = MySQLPacket.decodePacketHeader(buffer); @@ -250,6 +251,7 @@ class MySQLPacket { final payload = MySQLResultSetRowPacket.decode( Uint8List.sublistView(buffer, offset), numOfCols, + colDefs, ); return MySQLPacket( diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart index c696dce6..b16e1bad 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart @@ -56,6 +56,7 @@ class MySQLBinaryResultSetRowPacket extends MySQLPacketPayload { byteData, buffer, offset, + colDefs[x].charset, ); offset += parseResult.item2; values.add(parseResult.item1); diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart index 9c45cc67..f9289b47 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart @@ -10,7 +10,11 @@ class MySQLResultSetRowPacket extends MySQLPacketPayload { required this.values, }); - factory MySQLResultSetRowPacket.decode(Uint8List buffer, int numOfCols) { + factory MySQLResultSetRowPacket.decode( + Uint8List buffer, + int numOfCols, [ + List? colDefs, + ]) { final byteData = ByteData.sublistView(buffer); int offset = 0; @@ -24,7 +28,13 @@ class MySQLResultSetRowPacket extends MySQLPacketPayload { values.add(null); offset += 1; } else { - value = buffer.getUtf8LengthEncodedString(offset); + final binary = colDefs != null && + x < colDefs.length && + mysqlColumnHoldsRawBytes( + columnType: colDefs[x].type.intVal, + charset: colDefs[x].charset, + ); + value = buffer.getLengthEncodedString(offset, latin1Bytes: binary); values.add(value.item1); offset += value.item2; } diff --git a/third_party/mysql_client/test/mysql_packet_test.dart b/third_party/mysql_client/test/mysql_packet_test.dart index 4e8d98ca..a09159ad 100644 --- a/third_party/mysql_client/test/mysql_packet_test.dart +++ b/third_party/mysql_client/test/mysql_packet_test.dart @@ -241,6 +241,12 @@ void main() { expect(actual.item1, "def"); expect(actual.item2, 4); }); + test("testing getLengthEncodedString latin1 binary bytes", () { + final buffer = Uint8List.fromList([0x02, 0xaa, 0xde]); + final actual = buffer.getLengthEncodedString(0, latin1Bytes: true); + expect(actual.item1.codeUnits, [0xaa, 0xde]); + expect(actual.item2, 3); + }); test("testing getLengthEncodedString for long string", () { final buffer = Uint8List.fromList([ 0xfc,