diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index 2ee21b30d2..89cd97b446 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -415,6 +415,20 @@ "category": "database-driver", "homepage": "https://docs.tablepro.app/databases/typesense" }, + "weaviate": { + "target": "WeaviateDriverPlugin", + "bundleName": "WeaviateDriverPlugin", + "bundleId": "com.TablePro.WeaviateDriverPlugin", + "bundled": false, + "displayName": "Weaviate Driver", + "summary": "Weaviate driver over the REST API with a GraphQL console", + "databaseTypeIds": [ + "Weaviate" + ], + "icon": "weaviate-icon", + "category": "database-driver", + "homepage": "https://docs.tablepro.app/databases/weaviate" + }, "xlsx": { "target": "XLSXExport", "bundleName": "XLSXExport", diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e1e9b898..a6eed681a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- OceanBase MySQL-mode connection type. (#1748) +- Strikethrough on a row queued for deletion, and an underline on a new row or an edited value. - Google Cloud Spanner as a registry plugin over the REST API. (#1226, #2480) +- Weaviate as a registry REST plugin, collections as tables. (#1724) - TiDB and Databend connection types on the MySQL driver. (#1066, #2514) - Empty state in the inspector and the assistant for a connection that is not up. - **Check connections** in Settings > General, including Only when I use the connection. (#2700) @@ -20,9 +23,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Remove Invisible Characters** in the Query menu. (#2717) - **Show invisible characters** in Settings > Editor. (#2717) - Warnings in the SQL editor for full-width punctuation, curly quotes and non-ASCII spaces. (#2717) +- Highlight rules that color data grid rows or cells by value. (#2723) +- **Encoding** option for MySQL and MariaDB connections, with **UTF-8 via Latin 1** for databases written through a Latin 1 client. (#2725) +- Header, grid line, selection and focus colors in the theme editor. +- Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings. +- Reason shown in Settings > Appearance for a theme file that could not be loaded. +- Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes. +- **Refresh Materialized View…** on PostgreSQL, with a concurrent refresh where the view qualifies. (#2726) +- **Show DDL** and **Copy DDL** for views and materialized views. (#2726) +- **Edit Comment…** for PostgreSQL tables, views, materialized views and foreign tables. (#2726) +- UTF-16 LE, UTF-16 BE and Windows-1252 in the SQL import encoding menu. ### Changed +- Query confirmation shows the statement syntax highlighted and scrollable, with **Copy All** and the name of whatever asked to run it. (#2759) - 5 MB smaller app bundle. - 7 MB smaller DMG download. - Connect progress reads as a labelled bar, with a step named only where the app is waiting on something outside itself. @@ -32,22 +46,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Open Project Folder… in File > Import. - First-launch tour replaced by a one-page welcome sheet, shown again from Help > Getting Started. - Beancount connections held at Safe Mode Read-Only. (#2030) +- MySQL sessions on the server's default `utf8mb4` collation. +- Theme file format 2. Themes written for earlier versions are not read and need to be recreated. +- Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data & Results, and kept per Mac. +- Editor font size range of 10 to 24 points everywhere, including zoom. +- Structure editor options the connected PostgreSQL server does not support left out: generated columns before 12, BRIN before 9.5, and the MySQL-only FULLTEXT and SPATIAL index types. +- Structure tab read-only on a view, a materialized view, a foreign table or a system table outside PostgreSQL. (#2726) + +### Removed + +- Focus Border color in the theme editor. +- Interface, Sidebar and Toolbar color groups from the theme editor and the theme file. +- Fonts from the theme file. ### Fixed +- Query confirmation dialog cut to the first 200 characters of the statement. (#2759) +- Statement folded onto one line and cut at 400 characters in an MCP client's approval prompt. +- Blank line under "Are you sure you want to execute this query?" when confirming a rename. +- Row numbers and their divider painted over the find bar, the filter panel and the result tab bar. +- iOS PostgreSQL foreign-key listing after catalog quoting moved to the shared helper. (#2726) +- iOS MySQL driver after maintenance operations moved to a shared helper. +- Empty Columns tab and no autocomplete for PostgreSQL materialized views. (#2726) +- Discard restoring a different row than the one edited under a column value filter. +- Add Row under a column value filter selecting and opening the wrong row for editing. +- Data grid ignoring the theme's background, text, alternate row, NULL, boolean and row number colors. +- Interface, Sidebar and Toolbar colors having no effect anywhere in the app. +- Line Number color having no effect on the editor gutter. +- Default Light used for the dark theme when the chosen dark theme could not be loaded. +- Editor zoom lost on the next appearance or theme change. +- DDL, trigger and SQL import previews keeping the previous theme's colors. +- Query history preview ignoring the theme and the editor font. +- Theme editor changing the active theme instead of the theme selected for the slot being edited. +- Malformed theme files loading as Default Light under their own name. +- Content panes outside the editor and the data grid ignoring the theme. +- JSON and PHP tree values colored differently from the same values in the row inspector. +- Autocomplete icon colors ignoring the theme. +- Color with a typo in it rendering as a different color instead of being reported. +- Text past the first 64 KB of a UTF-16 SQL import arriving byte-swapped. +- SQL import failing on a file whose encoding is not UTF-8 when a character lands on a 64 KB boundary. +- Garbled non-Latin text saved from iPhone and iPad to MySQL servers that force a Latin 1 session. (#2725) +- **Encoding** ignored on iPhone and iPad by a MySQL connection synced from the Mac. (#2725) +- Binary MySQL columns shown as text on iPhone and iPad, and searched with `LIKE`. +- Crash opening a MySQL result whose column has no name on iPhone and iPad. +- Wrong SQLSTATE code in PostgreSQL, Redshift, CockroachDB and PGlite error messages. +- Read-only write explanation never shown on PostgreSQL servers. +- Trigger editing and the New Trigger template failing on PostgreSQL 13 and earlier. +- Reindexing a whole PostgreSQL database failing before PostgreSQL 16. +- Schema copy, check constraint rename, column reorder and role editing failing on older PostgreSQL servers. +- FULLTEXT and SPATIAL index types creating a plain B-tree index on PostgreSQL. +- Table list, foreign keys, triggers, check constraints, types and grants failing on PostgreSQL 9.1 to 9.5. (#2734) +- Wrong index column order on PostgreSQL 9.4 and earlier. +- Sequences missing from SQL dumps of PostgreSQL 9.6 and earlier. +- Create Database failing on PostgreSQL 9.6 and earlier. +- PostgreSQL unique and primary key indexes shown as plain indexes. +- PostgreSQL triggers shown as disabled. +- PostgreSQL `NOT VALID` check constraints shown as validated. +- Redshift `DISTKEY` missing from a table's indexes. +- `CYCLE` dropped from PostgreSQL sequences in SQL dumps. +- PostgreSQL index columns and enum labels containing a comma or a space split apart. +- Foreign keys to a partitioned table listed once per partition on PostgreSQL 12 and later. +- PostGIS values left as hex after switching schema. +- Open transaction aborted when a PostGIS value could not be converted. +- PostgreSQL all-tables metadata failing for a mixed-case table name. +- Default PostgreSQL schema taken from the schema list rather than the search path. - Safe Mode minimum from a configuration profile missing from the toolbar, the Database menu and the connection form. (#2030) +- PostgreSQL connection hanging after running `COPY FROM STDIN` or `COPY TO STDOUT` in the query editor and on iOS. +- Table, view and column comments missing from a PostgreSQL SQL export. (#2726) - Stop not ending queries on MySQL and MariaDB servers without TLS. +- Wrong results after MySQL retakes a dropped connection, on a session that had set a variable, a session setting or a database. +- Session state set by the `/*! ... */` statements a MySQL dump writes counting as a comment. - Users & Roles failing, Stop not ending queries and sequences listed as tables on TiDB servers opened as MySQL. - Update release notes show all changes for the offered version, with new features before fixes and properly formatted Markdown. The full changelog is also available from Help and Software Update settings. - Blank welcome window list when a search matched nothing and a favorite existed. - Welcome window reading No Connections while a tag filter hid every connection. - Favorited connection inside a group listed twice on the welcome window. +- Missing red wash on a row deleted together with a new, unsaved row. - Welcome window tag filter stuck on a tag no connection carries any more, hiding every connection. - Collapsing every group on the welcome window undone at the next launch. - Linked Folders and Team Library connections ignoring the welcome window search, with no context menu. - Dragging a connection in filtered welcome window results snapping back without moving it. - Welcome window context menu leaving linked connections out of a mixed selection. - - Idle metadata connections held open for the life of the app, up to six per connection. (#2700) - MongoDB connections reading as healthy after the server went away. (#2700) - Password prompt raised by a background reconnect, on whichever window was in front. (#2700) @@ -121,10 +200,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Line and paragraph separators (U+2028, U+2029) shown as line breaks the database does not see. (#2717) - Stop on Cloudflare D1, libSQL and Trino cancelling a sidebar read instead of the running query. - Numeric-looking filter values sent unquoted to text columns when a table first opens or after a foreign key jump. +- Missing column comments on PostgreSQL views, partitioned tables and foreign tables, and missing identity and generated flags on partitioned and foreign tables. +- Wrong primary key columns when another table reuses the key's constraint name on PostgreSQL and Redshift. +- Restore reported as failed after a complete restore into a PostgreSQL server older than `pg_restore`. +- Garbled non-Latin text saved to MySQL and MariaDB servers that force a Latin 1 session. (#2725) +- Garbled non-Latin text when restoring a MySQL or MariaDB SQL export through a Latin 1 client. (#2725) +- Curly quotes, € and other Windows-1252 symbols shown as invisible characters after `SET NAMES latin1`. +- `Illegal mix of collations` comparing a column with a user variable on MySQL 8. +- GEOMETRY values from a parameterized MySQL query shown as raw bytes. +- Earlier row's text repeated in later rows of a parameterized MySQL query once a value passed 64 KB. +- Wrong row deleted or updated after an edit took a row out of a column value filter. +- Garbled non-ASCII text on PostgreSQL databases not encoded in UTF-8 after `RESET ALL` or `DISCARD ALL`. +- Garbled or double-encoded non-ASCII text on iOS with PostgreSQL databases not encoded in UTF-8. +- Garbled non-ASCII text when restoring a PostgreSQL SQL export into a database not encoded in UTF-8. +- Display As formats and foreign key labels lost on a rename, and kept with column layouts after deleting a connection. +- Composite foreign keys listing mismatched column pairs on iOS, CockroachDB and Redshift. +- Foreign keys missing on iOS for a PostgreSQL role that does not own the table. +- Redshift foreign keys from other schemas shown on a table. +- PostgreSQL view definitions without `security_barrier`, `security_invoker` or the check option. (#2726) +- PostgreSQL view definitions that bind to another schema's tables when run elsewhere. (#2726) +- `CREATE TABLE` in Structure > DDL for a PostgreSQL view or materialized view. (#2726) +- **Edit View Definition** in the Database menu opening a same-named view from the browsed schema. (#2726) +- **Edit View Definition** enabled in the Database menu on a read-only connection. (#2726) +- Enum types and sequences written in front of a PostgreSQL view's DDL. (#2726) +- Display As formats lost on a rename, and kept with column layouts after deleting a connection. +- Garbled ClickHouse text whenever another value in the same result held binary data. +- Carriage returns, quotes, NUL bytes and Enum type names shown with backslash escapes on ClickHouse. +- Edits and deletes matching no row on ClickHouse tables with a binary value in the row. +- Binary ClickHouse values written as replacement characters by an export, an object copy and a compare. (#2725) +- ClickHouse export with a **Row limit** failing with a syntax error. (#2725) +- Non-ASCII SQL Server filter values turned into `?` and matching the wrong rows on non-Unicode collations. +- Empty structure, missing indexes and failed renames for non-ASCII SQL Server object names on non-Unicode collations. +- Changing a defaulted SQL Server column failing when a name contains a quote or non-ASCII text. +- CockroachDB DDL, view definition and index reads ignoring the schema of an object outside the current schema. (#2726) +- Silent replay of a PostgreSQL statement on a new connection after a drop, a lost `COMMIT` reported as saved included. +- Query timeout and startup commands missing after the PostgreSQL driver reconnected on its own. +- Server Dashboard sessions, metrics and slow queries failing on PostgreSQL 9.6 and earlier. +- One unanswered metric emptying the whole Server Dashboard metrics panel on PostgreSQL, Redshift and CockroachDB. +- MCP `get_server_dashboard` reporting a panel the server could not read as an empty list. +- Backup and restore failing on PostgreSQL 9.1 when the `pg_dump` or `pg_restore` found first is version 15 or later. +- Missing execution time in query plans on PostgreSQL 9.3 and earlier. +- Cross-engine copy and JSON import creating `jsonb` columns on PostgreSQL 9.3 and earlier. +- SQL Server edits and deletes matching no row when the row is identified by a binary column. +- SQL Server parameters sent to the wrong placeholders when a column name holds a question mark. +- Maintenance offered on views and sequences, where the server skips or refuses it. (#2726) +- Maintenance running against a same-named object in another schema. (#2726) +- Maintenance SQL preview showing a statement the app never runs. (#2726) +- `[` in a SQL Server filter value read as a wildcard. +- Non-ASCII text turned into `?` by Copy as INSERT, Copy as IN, Preview Referenced Row, compare scripts and column defaults on SQL Server. +- Structure tab offering column, index and constraint edits that views and materialized views refuse. (#2726) ### Security - BigQuery Google sign-in accepting an authorization response without PKCE or a state check. +- PostgreSQL sessions inheriting `standard_conforming_strings = off`, which let a backslash break out of any quoted literal. +- PostgreSQL catalog, comment and password literals escaped by quote doubling alone, which a backslash can break out of. (#2726) +- An MCP token without Full Access approving its own write and skipping TablePro's confirmation dialog. ## [0.73.0] - 2026-09-09 diff --git a/CLAUDE.md b/CLAUDE.md index 171a618c95..ec0fe83e60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ TablePro is a native macOS database client (SwiftUI + AppKit), a fast, lightweig - **Source**: `TablePro/` holds `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/` and `Theme/` - **Plugins**: `Plugins/` holds the `.tableplugin` bundles plus the `TableProPluginKit` shared framework. - **Bundled in app** (the 18 targets in the app's `copy: { destination: plugins }` phase in `project.yml`): MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV export, JSON export, SQL export, XLSX export, Markdown export, HTML export, XML export, MQL export, SQL import, JSON import, CSV import, XLSX import, CSV inspector. These ship inside the app bundle and their updates normally ride with the next app release. Six of them (`sqlite`, `clickhouse`, `redis`, `xlsx`, `mql`, `sqlimport`) also have registry arms in `build-plugin.yml`, so a bundled plugin can be published when users on an already-shipped app need the fix sooner. `scripts/build-plugin.sh:10` explains the flag that makes that work. - - **Registry-only** (the other 22): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, CloudflareR2SQL, DynamoDB, BigQuery, Spanner, LibSQL, Snowflake, Elasticsearch, Typesense, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka, Parquet export. Parquet is registry-only because it links its own copy of DuckDB, which does the encoding, and that is too large to ship in the app for one format. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory. + - **Registry-only** (the other 23): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, CloudflareR2SQL, DynamoDB, BigQuery, Spanner, LibSQL, Snowflake, Elasticsearch, Typesense, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka, Weaviate, Parquet export. Parquet is registry-only because it links its own copy of DuckDB, which does the encoding, and that is too large to ship in the app for one format. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory. - **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`) - **Static libs**: `Libs/` holds pre-built `.a` files and `Libs/ios/` holds the iOS xcframeworks. Both are downloaded by `scripts/download-libs.sh` and are not in git. - **SPM deps**: declared in `project.yml`. Vendored local packages under `LocalPackages/` (CodeEditSourceEditor, CodeEditTextView, CodeEditLanguages) and `Packages/` (TableProCore, TableProOracle); remote packages are Sparkle, swift-certificates and Yams. Revisions are pinned by the tracked `Package.resolved` inside each generated `.xcodeproj`. @@ -103,7 +103,7 @@ git add Libs/ios/checksums.sha256 && git commit -m "build: update iOS xcframewor Run `scripts/generate-project.sh` after editing any of those, and after adding, moving, or deleting a source file: XcodeGen globs sources at generation time, so a new file is not in the project until you regenerate. Changing signing in the Xcode UI is pointless, because the next generate discards it; set `TABLEPRO_DEVELOPMENT_TEAM` and `TABLEPRO_APP_BUNDLE_IDENTIFIER` in `Configs/Secrets.xcconfig` instead. -The 40 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 40, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag. +The 41 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 41, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag. ### Plugin System @@ -153,7 +153,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` ( ### Editor Architecture (CodeEditSourceEditor) -- **`ThemeEngine`**: the `@Observable` singleton that owns the active theme, and the single source of truth for editor colors and fonts +- **`ThemeEngine`**: the `@Observable` singleton that owns the active palette and the font caches. The catalog is `ThemeCatalog`, the choice is `ThemeResolver` (pure), and the file format is `ThemeDocument`. Colors come in two tiers: `palette` holds one `NSColor(name:dynamicProvider:)` per slot, which answers for the appearance a view is drawing in, and `resolved` is a static snapshot for the consumers that bake a color (`EditorTheme`, `CALayer`, the grid's per-cell draw path). Fonts are settings, not theme - **Two font domains, and a control never names a third**: `editorFonts` is the Editor Font setting and belongs to a view that also wears the editor's background, text color and syntax palette (the SQL editor, `JSONCodeEditor`, the fold and import previews). Every control that shows or edits a stored *value* takes `ThemeEngine.valueFont` / `valueFontSwiftUI`, which is the Data Grid Font setting, so one value reads the same in the grid cell, its inline editor, the row inspector, a cell popover and a pop-out window. Naming a system text style instead is the defect: it looks right only while the user leaves both settings equal, which is the default, so it ships invisibly (#2393). The inspector sets it once for the whole editor subtree in `InspectorFieldRow`, past the field label and the value menu, so a new field editor inherits it. The switch that decides it is exhaustive over `FieldEditorKind` on purpose: two `default`-armed switches are what let `.typePicker` escape the domain and render a structure row's Name and Type in two different fonts. AppKit will not honour it on a `Picker(.menu)`'s items, which `NSMenu` draws itself; that is the one place the rule stops. - **`TableProEditorTheme`**: adapter to CodeEdit's `EditorTheme` protocol; `ThemeEngine.makeEditorTheme()` builds it - **`CompletionEngine`**: framework-agnostic; **`QueryCompletionAdapter`** bridges to CodeEdit's `CodeSuggestionDelegate` @@ -199,6 +199,8 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool **An installed driver is a handle, not a live connection**: `ConnectionSession.driver` is held from the moment it is built until something replaces it, and a socket the server closed is indistinguishable from a working one until a ping asks. `reconnectDriver` and `reconnectSession` both disconnect it and leave it installed, so `hasDriver` stayed true for the whole of an outage and for good once the monitor gave up: the window went on showing rows, tabs and an enabled toolbar over a dead handle, `ensureConnected` returned early so Reconnect did nothing, and the connections strip painted the failure from `status` while the pane beside it disagreed. `ConnectionSession.liveness` is the single answer to "can this driver be believed", and `ConnectionWindowPhaseMachine.onSessionChanged` reads it before `hasDriver`. `status` cannot serve: it is `.connecting` throughout an ordinary database switch on the four engines that reconnect to perform one, and `.disconnected` is the struct's own default value. Four rules follow. The driver is never nil'd to signal this, because it is the handle every metadata read, every query route and the reconnect itself still goes through, and taking it away makes a database switch look like a dropped connection and trips the `exists && !hasDriver` arm into a permanent fake "Connecting". `recovering` is not a failure, so a blip that repairs itself inside `DatabaseManager.unreachableAfterAttempt` leaves the window untouched; the threshold is the fifth attempt because the 2, 4, 8, 16 backoff announces it a full 30 seconds after the ping that failed, which is one whole ping interval of not answering. Every give-up site and the threshold go through `markSessionUnreachable(_:startedWith:info:)`, whose driver-identity check is the generation guard a reconnect blocked in a C call needs, and every path that installs a working driver calls `markSessionLive` so the mark and its reason go together. And everything that reports connection health reads `ConnectionSession.reportedStatus`, never `status` directly, or the strip and the pane drift apart again. +**A theme reaches content, and `NSAppearance` is the only lever it has on chrome**: the sidebar, the trailing pane, the editor tab strip and the window background cannot be painted from a theme without fighting AppKit, so the app does not try. Measured on macOS 27 against a plain `NSSplitViewController` in a `.fullSizeContentView` window with a toolbar. The sidebar's material is a framework-owned `NSGlassEffectView` two levels above the pane's own root (`NSView > ContentHolderView > NSGlassEffectView > _NSSplitViewItemViewWrapper`), so painting the pane means covering a view AppKit owns, and `NSSplitViewItem.h` documents that translucent material as standard sidebar behaviour with no knob to turn it off. The pane is also full-height (`allowsFullHeightLayout` is `true` by default and its root reports `safeAreaInsets.top` of 66pt), so a fill pinned to that root paints the whole titlebar and toolbar band, which Apple's Liquid Glass guidance tells apps not to do and which is what broke native window tabs in Sublime Text. Sidebar selection is the same story from the other side: under `.sourceList` AppKit inserts its own `NSVisualEffectView` as the row's first subview and calls `drawSelection(in:)` zero times, so the documented override never runs; setting `selectionHighlightStyle = .none` does suppress it but collapses `interiorBackgroundStyle` from emphasized to normal, and that is the channel `NSTableCellView.backgroundStyle` publishes as SwiftUI's `backgroundProminence`, which every sidebar row label answers through `.primary` and `.secondary`. An absolute colour ignores prominence, so a themed label paints the wrong colour on a selected row under the default theme too. The editor tab strip is titlebar-resident (`NSTitlebarAccessoryViewController`), so it falls under the same rule as the toolbar. What remains is the one lever macOS does give: `ThemeEngine` sets `NSApp.appearance` from the appearance mode, a theme declares `light` or `dark`, and `ThemeSlotValidation` keeps a theme in the slot whose appearance it matches, so choosing a dark theme gives dark chrome. Every theme slot is namespaced `content.` for this reason, and `ThemeSlotCoverageTests` fails the build for a slot no call site reads, which is what let the old `ui`, `sidebar` and `toolbar` groups ship with zero readers for two years. + **The app runs the AppKit lifecycle, and AppKit owns the menu bar**: `main.swift` assigns the delegate before `NSApplicationMain`, and `MainMenuBuilder.install` runs in `applicationWillFinishLaunching`. Do not reintroduce a SwiftUI `App`. SwiftUI reconciles `NSApp.mainMenu` once shortly after launch and removes every item it did not build itself, and no hook can undo it: `NSApp.mainMenu` is not KVO-compliant, `didUpdateNotification`, `didBecomeKeyNotification` and the `applicationDidUpdate(_:)` delegate method never fire under `@NSApplicationDelegateAdaptor`, and `applicationDidBecomeActive` fires before the reconciliation. Only a wall-clock delay worked, which is why #2057 shipped a menu bar that vanished half a second after launch and had to be reverted (#2071). Every window is an `NSWindowController`; the Welcome window is one too, so closing it is an ordinary `close()` and the old "closed, never ordered out" rule no longer applies. **An emptied tab manager is not the same as "the user closed every tab"**: a coordinator torn down by a lost session has already emptied `tabManager.tabs`, so any persistence path that reads "no tabs" as "clear the saved tabs" wipes tabs the user never closed. The fix is that the teardown path cannot clear at all: `TabPersistenceCoordinator.saveAggregatedSync()`, which disconnect and window-close call, opens with `guard !tabs.isEmpty else { return }`. Clearing requires explicit consent and happens on the `closeTabsByUser` path instead. Keep those two paths separate; the moment a teardown path can write an empty aggregate, the bug is back. @@ -225,6 +227,12 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool **A pooled metadata read assumes a second connection reaches the same database, and an embedded engine breaks that assumption**: `MetadataConnectionPool` builds a whole new driver, so it is only correct when the database lives on a server the driver reconnects to. When the database lives *inside* the driver instance, the pool gets a different database: a second `duckdb_open(":memory:")` is a fresh empty database, and a second `duckdb_open` on the same *file* is a second independent read-write instance that the first never sees (DuckDB's file lock does not conflict within one process). The failure is silent, because an empty catalog is indistinguishable from "no tables", which is why #2108 survived a manual refresh. `supportsConnectionPooling` is the opt-out, and it is read only by `DatabaseManager.canPool`; DuckDB and PGlite set it `false`. SQLite-family engines keep pooling, because multi-connection access to one file is what they are built for. Two rules follow. First, every metadata read goes through `DatabaseManager.withMetadataDriver` so `metadataRoute` can apply the rule; reaching for `MetadataConnectionPool.shared.withDriver` directly bypasses it, which is how routines kept pooling after the sidebar stopped. Second, a capability with no `DriverPlugin` static is curated per type and `buildMetadataSnapshot` must carry it over from the built-in snapshot, or `register(snapshot:forTypeId:)` resets it to the struct default the moment the plugin loads. That is not hypothetical: it silently disabled MongoDB's `authenticationIsDatabaseScoped` (#1970) for every build that had the plugin installed. `registerVariant` already treats the curated entry as authoritative, which is the only reason PGlite's flag ever worked. +**A MySQL session's character set is server state, and the driver sets it rather than asking for it**: `MYSQL_SET_CHARSET_NAME` only puts a collation byte in the handshake, and the server is free to ignore it. `init_connect='SET NAMES latin1'` for any user without `CONNECTION_ADMIN`, MariaDB's `skip-character-set-client-handshake`, and a server with no `utf8mb4` all leave the session latin1 while libmariadb still reports `utf8mb4`. The driver sends UTF-8 either way, so every comment and value it wrote there was stored double-encoded, which is the `メール` of #2725, and a user-variable comparison on MySQL 8 failed with `ERROR 1267` under the handshake's `utf8mb4_general_ci`. So `MariaDBCharacterSet.establishSession`, which the macOS plugin and the iOS driver share, calls `mysql_set_character_set` on every connect path, falls back to a plain `SET NAMES utf8` (libmariadb itself rewrites `utf8` to `utf8mb3`, which a pre-5.5.3 server does not know), and keeps the server's own session when it refuses both rather than failing the connect: a MySQL-protocol engine that rejects `SET NAMES` must still connect. Two rules follow. Outgoing SQL stays the UTF-8 bytes of the Swift string and never follows a mid-session `SET NAMES`: a legacy dump that declares `SET NAMES latin1` over UTF-8 bytes restores byte-for-byte only because nothing re-encodes it, which is also what the `mysql` client does. And every result cell decodes through `MySQLColumnDecoding` by its field's own `charsetnr`, never as "UTF-8 and hope": a latin1 field reads UTF-8 first and then MySQL's latin1, which is cp1252 plus five C1 pass-through bytes and not Foundation's `.isoLatin1` or `.windowsCP1252`. The Foundation encodings in `MySQLCharacterSet` are a hand transcription of the server's tables; `scripts/check-mysql-charset-decoding.sh` diffs them against a live server, and a charset that disagrees stays out of the table. **UTF-8 via Latin 1** (`mysqlConnectionEncoding`) sets only `character_set_client` to latin1 and repairs the text client-side, so correctly stored text is never read back as `?`, which a latin1 results charset would do and a structure edit would then write back. + +**A reconnect the user did not ask for hands back a different session, so only a session holding nothing may replay a statement across one**: `MySQLPluginDriver.executeWithReconnect` retakes a connection the server dropped and runs the statement again, and the new session has none of the old one's state. Measured on MySQL 8.4.11 by killing the connection under it: `SELECT @probe` answered `NULL` where it had answered 42, `SELECT DATABASE()` answered the database the driver connects to over the one a `USE` had selected, and `@@SESSION.sql_mode` answered the server default over the session's `ANSI_QUOTES`, all three without raising. `mysqlMayReplay` is the single gate and reads `MySQLSessionFootprint.isClean`, not `hasOpenTransaction` alone; `blockingReason` is the same predicate in the user's words and gates the idle release, so `MySQLSessionFootprintTests` asserts the two agree for every flag. + +Four rules follow. A statement whose *answer* is session-scoped is not replayable even though it changes nothing and leaves the footprint clean: `LAST_INSERT_ID`, `ROW_COUNT`, `FOUND_ROWS` and `CONNECTION_ID` are in `mysqlSideEffectingMarkers` for that reason, measured as a fresh connection answering `SELECT LAST_INSERT_ID()` with `0`. The open transaction is the server's own answer rather than a reading of the text, through `mariadb_get_info(MARIADB_CONNECTION_SERVER_STATUS)` in `MariaDBPluginConnection.recordTransactionState` and `footprint.observeServerTransaction`: measured, that reports the transaction `SET autocommit = 0` plus a plain `SELECT` opens, the one inside `/*!40101 BEGIN */` and the one an `XA START` opens, none of which any text scan can see. Only the driver's own statements go in with `countsAsActivity: false`, and only because the driver puts them back itself: the query timeout, and the `USE` behind a database switch, which reconnects through `_activeDatabase` and refreshes `lastActivity` by hand so the idle timer still sees the switch as use. And `/*!40101 ... */` is not a comment, it is SQL the server runs, so `SQLStatementSplitting` keeps it whole rather than stripping it and `MySQLSessionFootprint` reads the body, trailing text included; a mysqldump preamble run from the editor sets the character set, the time zone and eight `@OLD_` variables inside them, and dropping them left the footprint reading clean. The version number is not checked against the server, so a statement the server is too old to run still counts, which holds a clean connection rather than releasing a dirty one. + **A MongoDB update or delete is anchored on `_id` or it does not run**: `generateDelete` used to fall back to a filter built from the remaining columns, which silently dropped every value it could not stringify (all binary) and then `deleteOne`d the first partial match, so a document with a binary `_id` could delete a different document. Both paths now skip with a logged warning instead, matching what `generateUpdate` already did. **Redis Cluster routing follows the server's own answer, and the curated table is only a fallback**: `RedisCommandRouting` fetches `COMMAND` once at connect, which supplies key positions on every Redis and, from Redis 7, the `request_policy` / `response_policy` tips that say which commands fan out and how their replies combine. A policy lives on the *subcommand* entry, not the container (`COMMAND INFO config` carries no tips at all; `config|set` is what says `all_nodes`), so the table is keyed `container|sub`. Redis 6 reports no tips, so a parsed reply is merged *over* the curated table rather than replacing it, or `DBSIZE`, `KEYS` and `FLUSHDB` would each go to one shard of a cluster and report success. The curated table is a hand-written list that has to agree with Redis and that nothing at runtime checks, so `scripts/check-redis-command-routing.sh [host] [port]` diffs it against a live Redis 7+; it found 32 disagreements the first time it ran, including a container command hashed on its literal subcommand name and `MSETNX` marked splittable when splitting it breaks the guarantee it exists for. Two rules follow. A container command takes no key of its own, so `OBJECT`, `MEMORY` and `CONFIG` declare no key positions and their keyed subcommands are listed separately, at the index the key sits in the *full* argument list (`OBJECT ENCODING k` puts it at 2, not 1). And an unknown command is routed as keyless rather than by hashing `argv[1]`: a keyless container like `SCRIPT LOAD` answers `+OK` from one node and never sends a `MOVED` to correct the guess. @@ -254,6 +262,7 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool | History drawer state | UserDefaults | `HistoryPanelPreferencesStorage` (per connection; visibility, connection scope, source/date/outcome filters; device-local). Live value held in `HistoryPanelState.forConnection`, cleared alongside `SharedSidebarState` when a session ends. | | Trusted external links | UserDefaults | `ExternalConnectionTrustStore` (keyed by database type + host + database + username + URL `name`, never the port; loopback hosts only, enforced on read and write). Consulted by `ExternalConnectionGate` before the external-URL confirmation alert. | | Last backup folder | UserDefaults | `PreferenceKeys.lastBackupDirectory` (one path, app-wide; device-local). Read by the Backup Dump sheet, which falls back to Downloads when the path no longer names a folder. | +| Editor and grid fonts | UserDefaults | `TypographySettings` (family and size for both domains, 10 to 24 pt; device-local, never synced). Sync replaces a settings category wholesale on apply, so a Mac on an older build would reset them, and zoom would push a record per keypress. | | Table load timings | JSON lines | `TableLoadHistoryStore` (`TableLoadHistory.jsonl`, mode `0600`; 7 days, 10,000 records or 5 MB, oldest first; device-local, never uploaded). Written by `TableLoadTracer` through `TableLoadSummarySink`, which is inert under XCTest. Durations and coarse buckets only: no table, schema, host, username, connection id, query text or error message. | ### Logging & Debugging diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index 65af0514c5..81e38528a1 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -163,10 +163,11 @@ extension SourceEditorConfiguration { (controller.textView as? SourceEditorTextView)?.statementHighlightColor = theme.statementHighlight - controller.gutterView.textColor = theme.text.color.withAlphaComponent(0.35) - controller.gutterView.statementRunRibbon.glyphColor = theme.text.color.withAlphaComponent(0.35) + let gutterColor = theme.lineNumber ?? theme.text.color.withAlphaComponent(0.35) + controller.gutterView.textColor = gutterColor + controller.gutterView.statementRunRibbon.glyphColor = gutterColor controller.gutterView.statementRunRibbon.hoveredGlyphColor = theme.text.color - controller.gutterView.foldingRibbon.chevronColor = theme.text.color.withAlphaComponent(0.35) + controller.gutterView.foldingRibbon.chevronColor = gutterColor controller.gutterView.foldingRibbon.collapsedChevronColor = theme.text.color.withAlphaComponent(0.65) controller.gutterView.foldingRibbon.hoveredChevronColor = theme.text.color controller.gutterView.foldingRibbon.foldExtentColor = theme.text.color.withAlphaComponent(0.2) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Theme/EditorTheme.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Theme/EditorTheme.swift index c408e822de..89b2838275 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Theme/EditorTheme.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Theme/EditorTheme.swift @@ -35,6 +35,10 @@ public struct EditorTheme: Equatable { public var statementHighlight: NSColor public var selection: NSColor + + /// The gutter's line numbers. `nil` keeps the derived look: the text colour at 35% alpha. + public var lineNumber: NSColor? + public var keywords: Attribute public var commands: Attribute public var types: Attribute @@ -56,6 +60,7 @@ public struct EditorTheme: Equatable { lineHighlight: NSColor, statementHighlight: NSColor = .clear, selection: NSColor, + lineNumber: NSColor? = nil, keywords: Attribute, commands: Attribute, types: Attribute, @@ -76,6 +81,7 @@ public struct EditorTheme: Equatable { self.lineHighlight = lineHighlight self.statementHighlight = statementHighlight self.selection = selection + self.lineNumber = lineNumber self.keywords = keywords self.commands = commands self.types = types diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 12982aeb23..c853d1efb3 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -23,6 +23,7 @@ let package = Package( .library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"]), .library(name: "TableProGoogleCloud", targets: ["TableProGoogleCloud"]), .library(name: "TableProSpannerCore", targets: ["TableProSpannerCore"]), + .library(name: "TableProWeaviateCore", targets: ["TableProWeaviateCore"]), .library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]), .library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"]) ], @@ -102,6 +103,11 @@ let package = Package( dependencies: ["TableProGoogleCloud"], path: "Sources/TableProSpannerCore" ), + .target( + name: "TableProWeaviateCore", + dependencies: [], + path: "Sources/TableProWeaviateCore" + ), .target( name: "TableProR2SQLCore", dependencies: [], @@ -162,6 +168,11 @@ let package = Package( dependencies: ["TableProSpannerCore", "TableProGoogleCloud"], path: "Tests/TableProSpannerCoreTests" ), + .testTarget( + name: "TableProWeaviateCoreTests", + dependencies: ["TableProWeaviateCore"], + path: "Tests/TableProWeaviateCoreTests" + ), .testTarget( name: "TableProR2SQLCoreTests", dependencies: ["TableProR2SQLCore"], diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 211eda5ad6..dc93bad95c 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -13,6 +13,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let mariadb = DatabaseType(rawValue: "MariaDB") public static let tidb = DatabaseType(rawValue: "TiDB") public static let databend = DatabaseType(rawValue: "Databend") + public static let oceanbase = DatabaseType(rawValue: "OceanBase") public static let postgresql = DatabaseType(rawValue: "PostgreSQL") public static let sqlite = DatabaseType(rawValue: "SQLite") public static let redis = DatabaseType(rawValue: "Redis") @@ -40,12 +41,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let trino = DatabaseType(rawValue: "Trino") public static let kafka = DatabaseType(rawValue: "Kafka") public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL") + public static let weaviate = DatabaseType(rawValue: "Weaviate") public static let allKnownTypes: [DatabaseType] = [ - .mysql, .mariadb, .tidb, .databend, .postgresql, .sqlite, .redis, .mongodb, + .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .dameng, .duckdb, .cassandra, .redshift, .etcd, .cloudflareD1, .dynamodb, .bigquery, .spanner, .snowflake, .libsql, .beancount, - .surrealdb, .teradata, .trino, .kafka, .cloudflareR2SQL + .surrealdb, .teradata, .trino, .kafka, .cloudflareR2SQL, .weaviate ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -55,6 +57,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .mariadb: return "mariadb-icon" case .tidb: return "tidb-icon" case .databend: return "databend-icon" + case .oceanbase: return "oceanbase-icon" case .postgresql: return "postgresql-icon" case .redshift: return "redshift-icon" case .sqlite: return "sqlite-icon" @@ -79,13 +82,14 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .trino: return "trino-icon" case .kafka: return "kafka-icon" case .cloudflareR2SQL: return "cloudflare-r2-sql-icon" + case .weaviate: return "weaviate-icon" default: return "externaldrive" } } public var pluginTypeId: String { switch self { - case .mariadb, .tidb, .databend: return DatabaseType.mysql.rawValue + case .mariadb, .tidb, .databend, .oceanbase: return DatabaseType.mysql.rawValue case .redshift: return DatabaseType.postgresql.rawValue default: return rawValue } diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLParameterBatch.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLParameterBatch.swift new file mode 100644 index 0000000000..98998fad79 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLParameterBatch.swift @@ -0,0 +1,146 @@ +import Foundation + +/// One value bound to a `?` placeholder, in the three shapes a cell can arrive in. +public enum MSSQLParameter: Equatable, Sendable { + case null + case text(String) + case bytes(Data) +} + +/// Rewrites a `?`-placeholder query into the `EXEC sp_executesql` form SQL Server takes, and +/// declares each parameter as the type its value actually is. +/// +/// Every parameter used to be declared `NVARCHAR(MAX)` and assigned from the value's text. A +/// binary value has no text, so it was assigned `NULL`: a row matched on a `VARBINARY` column +/// found nothing, and the delete or update that followed reported success having touched no row. +/// A `VARBINARY(MAX)` parameter assigned a `0x…` literal is what that value is. +public enum MSSQLParameterBatch { + public struct Statement: Equatable, Sendable { + public let query: String + public let declarations: String + public let assignments: String + + public var isEmpty: Bool { declarations.isEmpty } + } + + public static func spExecuteSql(query: String, parameters: [MSSQLParameter]) -> Statement { + let replaced = replacePlaceholders(in: query, limit: parameters.count) + guard replaced.count > 0 else { + return Statement(query: replaced.query, declarations: "", assignments: "") + } + let used = parameters.prefix(replaced.count) + let declarations = used.enumerated() + .map { "@p\($0.offset + 1) \(declaredType(of: $0.element))" } + .joined(separator: ", ") + let assignments = used.enumerated() + .map { "@p\($0.offset + 1) = \(literal(for: $0.element))" } + .joined(separator: ", ") + return Statement(query: replaced.query, declarations: declarations, assignments: assignments) + } + + private static func declaredType(of parameter: MSSQLParameter) -> String { + switch parameter { + case .bytes: + return "VARBINARY(MAX)" + case .text, .null: + return "NVARCHAR(MAX)" + } + } + + private static func literal(for parameter: MSSQLParameter) -> String { + switch parameter { + case .null: + return "NULL" + case .text(let value): + return MSSQLStringLiteral.quoted(value) + case .bytes(let data): + return hexLiteral(data) + } + } + + /// `0x` on its own is the empty binary, which is what SQL Server writes for one and what it + /// reads back. There is no zero-length form to special-case. + private static func hexLiteral(_ data: Data) -> String { + var literal = "0x" + literal.reserveCapacity(2 + data.count * 2) + for byte in data { + literal.append(String(format: "%02X", byte)) + } + return literal + } + + /// A `?` inside a string literal or a quoted identifier is data, not a placeholder, and the + /// doubled `''`, `""` and `]]` that carry one have to be stepped over rather than read as the + /// end of the literal. + /// + /// The bracket is the one T-SQL spells differently from everything else, and it was missing: + /// a column named `[we?ird]` took `@p1`, which shifted every parameter after it by one and + /// sent the values to the wrong placeholders. + private static func replacePlaceholders(in query: String, limit: Int) -> (query: String, count: Int) { + var converted = "" + var count = 0 + var quoting = Quoting.code + let characters = Array(query) + + var index = 0 + while index < characters.count { + let character = characters[index] + let next = index + 1 < characters.count ? characters[index + 1] : nil + + if let doubled = quoting.doubledDelimiter, character == doubled, next == doubled { + converted.append(doubled) + converted.append(doubled) + index += 2 + continue + } + + quoting = quoting.after(character) + + if character == "?", quoting == .code, count < limit { + count += 1 + converted.append("@p\(count)") + } else { + converted.append(character) + } + index += 1 + } + + return (converted, count) + } + + private enum Quoting { + case code + case singleQuote + case doubleQuote + case bracket + + /// What a doubled occurrence of this state's own delimiter escapes. A bracket identifier + /// escapes its closing `]`, not the `[` that opened it. + var doubledDelimiter: Character? { + switch self { + case .code: return nil + case .singleQuote: return "'" + case .doubleQuote: return "\"" + case .bracket: return "]" + } + } + + func after(_ character: Character) -> Quoting { + switch self { + case .code: + switch character { + case "'": return .singleQuote + case "\"": return .doubleQuote + case "[": return .bracket + default: return .code + } + case .singleQuote: + return character == "'" ? .code : self + case .doubleQuote: + return character == "\"" ? .code : self + case .bracket: + return character == "]" ? .code : self + } + } + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift index b701a5e2b9..aaf6df3da3 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift @@ -2,7 +2,7 @@ import Foundation public enum MSSQLSchemaQueries { public static func escape(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "''") + MSSQLStringLiteral.escaped(value) } public static func escapeBracket(_ value: String) -> String { @@ -127,19 +127,19 @@ public enum MSSQLSchemaQueries { """ public static func tables(schema: String) -> String { - let s = escape(schema) + let s = MSSQLStringLiteral.quoted(schema) return """ SELECT t.TABLE_NAME, t.TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES t - WHERE t.TABLE_SCHEMA = '\(s)' + WHERE t.TABLE_SCHEMA = \(s) AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW') ORDER BY t.TABLE_NAME """ } public static func columns(schema: String, table: String) -> String { - let s = escape(schema) - let t = escape(table) + let s = MSSQLStringLiteral.quoted(schema) + let t = MSSQLStringLiteral.quoted(table) return """ SELECT c.COLUMN_NAME, @@ -160,17 +160,17 @@ public enum MSSQLSchemaQueries { ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - AND tc.TABLE_SCHEMA = '\(s)' - AND tc.TABLE_NAME = '\(t)' + AND tc.TABLE_SCHEMA = \(s) + AND tc.TABLE_NAME = \(t) ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME - WHERE c.TABLE_NAME = '\(t)' - AND c.TABLE_SCHEMA = '\(s)' + WHERE c.TABLE_NAME = \(t) + AND c.TABLE_SCHEMA = \(s) ORDER BY c.ORDINAL_POSITION """ } public static func indexes(schema: String, table: String) -> String { - let object = bracketed(schema: schema, table: table) + let object = MSSQLStringLiteral.quoted(bracketed(schema: schema, table: table)) return """ SELECT i.name, i.is_unique, i.is_primary_key, c.name AS column_name FROM sys.indexes i @@ -178,15 +178,15 @@ public enum MSSQLSchemaQueries { ON i.object_id = ic.object_id AND i.index_id = ic.index_id JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id - WHERE i.object_id = OBJECT_ID('\(object)') + WHERE i.object_id = OBJECT_ID(\(object)) AND i.name IS NOT NULL ORDER BY i.index_id, ic.key_ordinal """ } public static func foreignKeys(schema: String, table: String) -> String { - let s = escape(schema) - let t = escape(table) + let s = MSSQLStringLiteral.quoted(schema) + let t = MSSQLStringLiteral.quoted(table) return """ SELECT fk.name AS constraint_name, @@ -204,7 +204,7 @@ public enum MSSQLSchemaQueries { JOIN sys.schemas sr ON tr.schema_id = sr.schema_id JOIN sys.columns cr ON fkc.referenced_object_id = cr.object_id AND fkc.referenced_column_id = cr.column_id - WHERE tp.name = '\(t)' AND s.name = '\(s)' + WHERE tp.name = \(t) AND s.name = \(s) ORDER BY fk.name """ } diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLStringLiteral.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLStringLiteral.swift new file mode 100644 index 0000000000..df9a8f3a02 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLStringLiteral.swift @@ -0,0 +1,62 @@ +import Foundation + +/// The single owner of how SQL Server spells a string literal carrying user text. +/// +/// A plain `'…'` is a `varchar` literal, so the server converts it to the database's collation +/// code page while it parses the batch. On a non-Unicode collation every character outside that +/// page becomes `?`, and it happens whatever the target column is: inserting `'日本語'` into an +/// `NVARCHAR(50)` column stores `??????`, and `WHERE n = '日本語'` then matches the row that was +/// damaged earlier rather than the row that holds the text. `N'…'` is an `nvarchar` literal and is +/// converted by nothing. It costs nothing on ASCII, where `N'abc' = 'abc'`. +/// +/// Only text the user or the catalog supplies goes through here. A number, an identifier, a `0x` +/// binary literal and a fixed catalog constant such as `'PRIMARY KEY'` must stay as they are. +public enum MSSQLStringLiteral { + public static func escaped(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + public static func quoted(_ value: String) -> String { + "N'\(escaped(value))'" + } + + /// `LIKE` takes its pattern as an ordinary string literal, so the prefix belongs here too. The + /// wildcards the user typed are escaped and declared with `ESCAPE '\'`, which stays a plain + /// literal because a backslash is ASCII. + public static func likePattern(_ value: String, prefixWildcard: Bool, suffixWildcard: Bool) -> String { + let body = escapeForLike(value) + let pattern = (prefixWildcard ? "%" : "") + body + (suffixWildcard ? "%" : "") + return "N'\(pattern)' ESCAPE '\\'" + } + + /// `[` opens a character class in a T-SQL `LIKE` pattern, which no other engine this app + /// speaks to does, so a value holding one matched by its contents instead of by itself: a + /// search for `a[bc]` found `ab` and `ac` and never the text the user typed. `]` outside a + /// class is an ordinary character and stays as it is. + public static func escapeForLike(_ value: String) -> String { + escaped( + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + .replacingOccurrences(of: "[", with: "\\[") + ) + } + + /// The `LIKE` arms of the shared filter builder write their own literal rather than asking the + /// driver for one, so SQL Server answers them here and lets everything else fall through. + public static func likeCondition(quotedColumn: String, op: String, value: String) -> String? { + switch op { + case "CONTAINS": + return "\(quotedColumn) LIKE \(likePattern(value, prefixWildcard: true, suffixWildcard: true))" + case "NOT CONTAINS": + return "\(quotedColumn) NOT LIKE \(likePattern(value, prefixWildcard: true, suffixWildcard: true))" + case "STARTS WITH": + return "\(quotedColumn) LIKE \(likePattern(value, prefixWildcard: false, suffixWildcard: true))" + case "ENDS WITH": + return "\(quotedColumn) LIKE \(likePattern(value, prefixWildcard: true, suffixWildcard: false))" + default: + return nil + } + } +} diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift index e6abad6f91..860a2148e2 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncMetadataStorage.swift @@ -73,9 +73,14 @@ public final class SyncMetadataStorage: @unchecked Sendable { } public func removeDirty(_ id: String, type: SyncRecordType) { - var ids = dirtyIds(for: type) - ids.remove(id) - saveDirtyIds(ids, for: type) + removeDirty([id], type: type) + } + + public func removeDirty(_ ids: [String], type: SyncRecordType) { + guard !ids.isEmpty else { return } + var current = dirtyIds(for: type) + current.subtract(ids) + saveDirtyIds(current, for: type) } public func clearDirty(type: SyncRecordType) { @@ -95,8 +100,13 @@ public final class SyncMetadataStorage: @unchecked Sendable { } public func addTombstone(_ id: String, type: SyncRecordType) { + addTombstones([id], type: type) + } + + public func addTombstones(_ ids: [String], type: SyncRecordType) { + guard !ids.isEmpty else { return } var current = tombstones(for: type) - current.append(Tombstone(id: id)) + current.append(contentsOf: ids.map { Tombstone(id: $0) }) saveTombstones(current, for: type) } diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateAuth.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateAuth.swift new file mode 100644 index 0000000000..bf2a74a611 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateAuth.swift @@ -0,0 +1,93 @@ +import Foundation + +public enum WeaviateFieldID { + public static let authMethod = "wvAuthMethod" + public static let apiKey = "wvApiKey" + public static let skipTLSVerify = "wvSkipTLSVerify" +} + +public enum WeaviateAuthMethod: String, Sendable, Equatable { + case none + case apiKey +} + +public struct WeaviateAuth: Sendable, Equatable { + public let method: WeaviateAuthMethod + public let apiKey: String + + public init(method: WeaviateAuthMethod, apiKey: String = "") { + self.method = method + self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public static func parse(fields: [String: String]) -> WeaviateAuth { + let raw = fields[WeaviateFieldID.authMethod] ?? WeaviateAuthMethod.none.rawValue + let method = WeaviateAuthMethod(rawValue: raw) ?? .none + return WeaviateAuth(method: method, apiKey: fields[WeaviateFieldID.apiKey] ?? "") + } + + /// A key left in the form after the user switches back to None must not be sent: the form + /// keeps the field's text, and only the method says whether the connection is authenticated. + public var authorizationHeader: String? { + guard method == .apiKey, !apiKey.isEmpty else { return nil } + return "Bearer \(apiKey)" + } +} + +public struct WeaviateConnectionSettings: Sendable, Equatable { + public static let defaultPort = 8_080 + + public let host: String + public let port: Int + public let usesTLS: Bool + public let auth: WeaviateAuth + public let skipTLSVerify: Bool + + public init( + host: String, + port: Int, + usesTLS: Bool, + auth: WeaviateAuth, + skipTLSVerify: Bool + ) { + self.host = host.trimmingCharacters(in: .whitespacesAndNewlines) + self.port = port + self.usesTLS = usesTLS + self.auth = auth + self.skipTLSVerify = skipTLSVerify + } + + public static func parse( + host: String, + port: Int, + usesTLS: Bool, + fields: [String: String] + ) throws -> WeaviateConnectionSettings { + let resolvedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedPort = port > 0 ? port : defaultPort + let skipTLS = fields[WeaviateFieldID.skipTLSVerify] == "true" + let settings = WeaviateConnectionSettings( + host: resolvedHost.isEmpty ? "localhost" : resolvedHost, + port: resolvedPort, + usesTLS: usesTLS, + auth: WeaviateAuth.parse(fields: fields), + skipTLSVerify: skipTLS + ) + _ = try settings.baseURL() + if settings.auth.method == .apiKey, settings.auth.apiKey.isEmpty { + throw WeaviateError.configuration(String(localized: "Enter a Weaviate API key.")) + } + return settings + } + + public func baseURL() throws -> URL { + var components = URLComponents() + components.scheme = usesTLS ? "https" : "http" + components.host = host + components.port = port + guard let url = components.url else { + throw WeaviateError.configuration(String(localized: "The host is not valid in a URL.")) + } + return url + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateError.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateError.swift new file mode 100644 index 0000000000..854bfa4951 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateError.swift @@ -0,0 +1,57 @@ +import Foundation + +public enum WeaviateError: Error, LocalizedError, Equatable, Sendable { + case configuration(String) + case notConnected + case transport(String) + case authentication(String) + case api(status: Int, message: String) + case malformedResponse(String) + case cancelled + + public var errorDescription: String? { + switch self { + case .configuration(let detail), .transport(let detail), .malformedResponse(let detail): + return detail + case .notConnected: + return String(localized: "Not connected to Weaviate.") + case .authentication(let detail): + return detail + case .api(_, let message): + return message + case .cancelled: + return String(localized: "The request was cancelled.") + } + } + + public static func from(status: Int, body: Data) -> WeaviateError { + let message = apiMessage(from: body) + ?? String(format: String(localized: "Weaviate returned HTTP %d."), status) + if status == 401 || status == 403 { + return .authentication(message) + } + return .api(status: status, message: message) + } + + public static func apiMessage(from body: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: body) else { + let text = String(data: body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return (text?.isEmpty ?? true) ? nil : text + } + if let object = json as? [String: Any] { + if let errors = object["error"] as? [[String: Any]] { + let messages = errors.compactMap { $0["message"] as? String }.filter { !$0.isEmpty } + if !messages.isEmpty { + return messages.joined(separator: "\n") + } + } + if let error = object["error"] as? String, !error.isEmpty { + return error + } + if let message = object["message"] as? String, !message.isEmpty { + return message + } + } + return nil + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateFilterBuilder.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateFilterBuilder.swift new file mode 100644 index 0000000000..d9235f1578 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateFilterBuilder.swift @@ -0,0 +1,309 @@ +import Foundation + +public enum WeaviateFilterError: Error, LocalizedError, Equatable { + case unsupportedOperator(String) + case missingUpperBound(column: String) + case notANumber(column: String, value: String) + case notABoolean(column: String, value: String) + case notADate(column: String, value: String) + case emptyList(column: String) + case comparisonNeedsNumberOrDate(column: String, op: String) + case textMatchNeedsText(column: String, op: String) + case vectorNotFilterable(column: String) + + public var errorDescription: String? { + switch self { + case .unsupportedOperator(let op): + return String(format: String(localized: "Weaviate cannot filter with %@."), op) + case .missingUpperBound(let column): + return String(format: String(localized: "BETWEEN on %@ needs an upper bound."), column) + case .notANumber(let column, let value): + return String( + format: String(localized: "%@ is a numeric property, and %@ is not a number."), + column, value + ) + case .notABoolean(let column, let value): + return String( + format: String(localized: "%@ is a boolean property, so it matches only true or false, not %@."), + column, value + ) + case .notADate(let column, let value): + return String( + format: String(localized: "%@ is a date property, and %@ is not an RFC 3339 timestamp such as 2024-01-31T00:00:00Z."), + column, value + ) + case .emptyList(let column): + return String(format: String(localized: "IN on %@ needs at least one value."), column) + case .comparisonNeedsNumberOrDate(let column, let op): + return String( + format: String(localized: "Weaviate compares only numbers and dates with %@, and %@ is neither."), + op, column + ) + case .textMatchNeedsText(let column, let op): + return String( + format: String(localized: "Weaviate matches text with %@, and %@ is not a text property."), + op, column + ) + case .vectorNotFilterable(let column): + return String(format: String(localized: "Weaviate cannot filter on %@."), column) + } + } +} + +/// Weaviate has no array value field: an `int[]` property filters with `valueInt`, so the kind is +/// always the element's. +public enum WeaviateValueKind: String, Sendable, Equatable { + case text + case uuid + case int + case number + case boolean + case date + + public var graphQLField: String { + switch self { + case .text, .uuid: return "valueText" + case .int: return "valueInt" + case .number: return "valueNumber" + case .boolean: return "valueBoolean" + case .date: return "valueDate" + } + } + + public var isOrdered: Bool { + self == .int || self == .number || self == .date + } + + /// `Like` compiles to a regex over the inverted index, which Weaviate only builds for text. + public var acceptsPatternMatch: Bool { + self == .text + } + + public static func forDataType(_ dataType: String) -> WeaviateValueKind { + var name = dataType.trimmingCharacters(in: .whitespaces).lowercased() + while name.hasSuffix("[]") { + name = String(name.dropLast(2)) + } + switch name { + case "int": return .int + case "number": return .number + case "boolean", "bool": return .boolean + case "date": return .date + case "uuid": return .uuid + default: return .text + } + } +} + +public enum WeaviateFilterBuilder { + public static func graphQLWhere( + filters: [WeaviateFilterSpec], + logicMode: String, + types: [String: String] + ) throws -> String? { + guard !filters.isEmpty else { return nil } + let operands = try filters.map { try operand(for: $0, types: types) } + guard operands.count > 1 else { return operands[0] } + let op = logicMode.uppercased() == "OR" ? "Or" : "And" + return "{ operator: \(op) operands: [\(operands.joined(separator: " "))] }" + } + + public static func operand(for filter: WeaviateFilterSpec, types: [String: String]) throws -> String { + let column = filter.column + guard column != WeaviateSchema.vectorColumn else { + throw WeaviateFilterError.vectorNotFilterable(column: column) + } + let path = column == WeaviateSchema.uuidColumn ? WeaviateSchema.uuidGraphQLPath : column + let kind = column == WeaviateSchema.uuidColumn + ? WeaviateValueKind.text + : WeaviateValueKind.forDataType(types[column] ?? "text") + let op = filter.op.uppercased() + + switch op { + case "=": + return try comparison("Equal", path: path, column: column, value: filter.value, kind: kind) + case "!=", "<>": + return try comparison("NotEqual", path: path, column: column, value: filter.value, kind: kind) + case ">", ">=", "<", "<=": + guard kind.isOrdered else { + throw WeaviateFilterError.comparisonNeedsNumberOrDate(column: column, op: op) + } + return try comparison(orderedOperator(op), path: path, column: column, value: filter.value, kind: kind) + case "CONTAINS": + return try like(pattern: "*\(filter.value)*", path: path, column: column, kind: kind, op: op) + case "NOT CONTAINS": + return negated(try like(pattern: "*\(filter.value)*", path: path, column: column, kind: kind, op: op)) + case "STARTS WITH": + return try like(pattern: "\(filter.value)*", path: path, column: column, kind: kind, op: op) + case "ENDS WITH": + return try like(pattern: "*\(filter.value)", path: path, column: column, kind: kind, op: op) + case "IN": + return try containsList("ContainsAny", filter.value, path: path, column: column, kind: kind) + case "NOT IN": + return try containsList("ContainsNone", filter.value, path: path, column: column, kind: kind) + case "BETWEEN": + return try between(filter, path: path, column: column, kind: kind) + case "IS NULL": + return "{ path: [\"\(escape(path))\"] operator: IsNull valueBoolean: true }" + case "IS NOT NULL": + return "{ path: [\"\(escape(path))\"] operator: IsNull valueBoolean: false }" + case "IS EMPTY": + return try emptiness("Equal", path: path, column: column, kind: kind, op: op) + case "IS NOT EMPTY": + return try emptiness("GreaterThan", path: path, column: column, kind: kind, op: op) + default: + throw WeaviateFilterError.unsupportedOperator(op) + } + } + + private static func orderedOperator(_ op: String) -> String { + switch op { + case ">": return "GreaterThan" + case ">=": return "GreaterThanEqual" + case "<": return "LessThan" + default: return "LessThanEqual" + } + } + + private static func comparison( + _ operatorName: String, + path: String, + column: String, + value: String, + kind: WeaviateValueKind + ) throws -> String { + let literal = try literal(value, column: column, kind: kind) + return "{ path: [\"\(escape(path))\"] operator: \(operatorName) \(kind.graphQLField): \(literal) }" + } + + /// Weaviate compiles a `Like` value into a regex over the inverted index. An int, number or + /// date property crashes that compile, and a uuid property is refused outright. + private static func like( + pattern: String, + path: String, + column: String, + kind: WeaviateValueKind, + op: String + ) throws -> String { + guard kind.acceptsPatternMatch else { + throw WeaviateFilterError.textMatchNeedsText(column: column, op: op) + } + return "{ path: [\"\(escape(path))\"] operator: Like valueText: \"\(escape(pattern))\" }" + } + + private static func containsList( + _ operatorName: String, + _ value: String, + path: String, + column: String, + kind: WeaviateValueKind + ) throws -> String { + let parts = value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !parts.isEmpty else { + throw WeaviateFilterError.emptyList(column: column) + } + let literals = try parts.map { try literal($0, column: column, kind: kind) } + return "{ path: [\"\(escape(path))\"] operator: \(operatorName) \(kind.graphQLField): [\(literals.joined(separator: ", "))] }" + } + + /// Weaviate has no "is empty": it counts with `len(prop)`, which needs `indexPropertyLength` on + /// the collection and answers with what to turn on when it is off. There is no `len(id)`, and a + /// uuid property has no length either. + private static func emptiness( + _ operatorName: String, + path: String, + column: String, + kind: WeaviateValueKind, + op: String + ) throws -> String { + guard kind == .text, column != WeaviateSchema.uuidColumn else { + throw WeaviateFilterError.textMatchNeedsText(column: column, op: op) + } + return "{ path: [\"len(\(escape(path)))\"] operator: \(operatorName) valueInt: 0 }" + } + + private static func between( + _ filter: WeaviateFilterSpec, + path: String, + column: String, + kind: WeaviateValueKind + ) throws -> String { + guard kind.isOrdered else { + throw WeaviateFilterError.comparisonNeedsNumberOrDate(column: column, op: "BETWEEN") + } + guard let upperBound = filter.secondValue, !upperBound.trimmingCharacters(in: .whitespaces).isEmpty else { + throw WeaviateFilterError.missingUpperBound(column: column) + } + let lower = try comparison( + "GreaterThanEqual", path: path, column: column, value: filter.value, kind: kind + ) + let upper = try comparison( + "LessThanEqual", path: path, column: column, value: upperBound, kind: kind + ) + return "{ operator: And operands: [\(lower) \(upper)] }" + } + + private static func negated(_ operand: String) -> String { + "{ operator: Not operands: [\(operand)] }" + } + + private static func literal(_ value: String, column: String, kind: WeaviateValueKind) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespaces) + switch kind { + case .text, .uuid: + return "\"\(escape(value))\"" + case .int: + guard let number = Int(trimmed) else { + throw WeaviateFilterError.notANumber(column: column, value: value) + } + return String(number) + case .number: + guard let number = Double(trimmed) else { + throw WeaviateFilterError.notANumber(column: column, value: value) + } + return String(number) + case .boolean: + switch trimmed.lowercased() { + case "true", "1": return "true" + case "false", "0": return "false" + default: throw WeaviateFilterError.notABoolean(column: column, value: value) + } + case .date: + guard WeaviateDateLiteral.isRFC3339(trimmed) else { + throw WeaviateFilterError.notADate(column: column, value: value) + } + return "\"\(escape(trimmed))\"" + } + } + + static func escape(_ value: String) -> String { + var result = "" + result.reserveCapacity(value.count) + for character in value { + switch character { + case "\\": result += "\\\\" + case "\"": result += "\\\"" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + default: result.append(character) + } + } + return result + } +} + +/// Weaviate parses a `valueDate` with Go's RFC 3339 layout and answers +/// `trying parse time as RFC3339 string` for anything else, including a bare `2024-01-31`. +public enum WeaviateDateLiteral { + public static func isRFC3339(_ value: String) -> Bool { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + if formatter.date(from: value) != nil { return true } + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) != nil + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateGraphQL.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateGraphQL.swift new file mode 100644 index 0000000000..0705167481 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateGraphQL.swift @@ -0,0 +1,140 @@ +import Foundation + +public enum WeaviateGraphQL { + public static func getQuery( + collection: String, + properties: [String], + limit: Int, + offset: Int, + sorts: [WeaviateSortSpec], + filters: [WeaviateFilterSpec], + logicMode: String, + schema: [String: WeaviateProperty], + includeVector: Bool = true + ) throws -> String { + let types = schema.mapValues(\.dataType) + let fields = properties + .filter { $0 != WeaviateSchema.uuidColumn && $0 != WeaviateSchema.vectorColumn } + .compactMap { selection(for: $0, schema: schema) } + .joined(separator: " ") + var args: [String] = ["limit: \(max(limit, 0))", "offset: \(max(offset, 0))"] + if let whereClause = try WeaviateFilterBuilder.graphQLWhere( + filters: filters, logicMode: logicMode, types: types + ) { + args.append("where: \(whereClause)") + } + let sortArgs = sorts.compactMap { sort -> String? in + guard let path = sortPath(for: sort) else { return nil } + let order = sort.ascending ? "asc" : "desc" + return "{ path: [\"\(WeaviateFilterBuilder.escape(path))\"] order: \(order) }" + } + if !sortArgs.isEmpty { + args.append("sort: [\(sortArgs.joined(separator: " "))]") + } + let argumentList = args.joined(separator: ", ") + let additional = includeVector ? "id vector" : "id" + return """ + { Get { \(collection)(\(argumentList)) { \(fields) _additional { \(additional) } } } } + """ + } + + /// A structured property needs its own sub-selection, and an `object` with no declared nested + /// properties has nothing to select, so it is left out rather than failing the query. + private static func selection(for name: String, schema: [String: WeaviateProperty]) -> String? { + guard let property = schema[name] else { return name } + switch WeaviatePropertyShape.of(property) { + case .scalar: + return name + case .geoCoordinates: + return "\(name) { latitude longitude }" + case .phoneNumber: + return "\(name) { input internationalFormatted nationalFormatted countryCode national valid defaultCountry }" + case .object: + let nested = property.nestedProperties + .compactMap { selection(for: $0.name, schema: [$0.name: $0]) } + .joined(separator: " ") + return nested.isEmpty ? nil : "\(name) { \(nested) }" + case .crossReference(let targets): + let fragments = targets.map { "... on \($0) { _additional { id } }" }.joined(separator: " ") + return "\(name) { \(fragments) }" + } + } + + /// `vector` is a grid column rather than a property, so Weaviate has nothing to sort on. Every + /// real property is passed through: a type it cannot sort, such as uuid, is reported by the + /// server, which beats painting a sort chevron over rows in insertion order. + private static func sortPath(for sort: WeaviateSortSpec) -> String? { + if sort.column == WeaviateSchema.uuidColumn { + return WeaviateSchema.uuidGraphQLPath + } + return sort.column == WeaviateSchema.vectorColumn ? nil : sort.column + } + + public static func looksLikeGraphQL(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("{") { return true } + let lowered = trimmed.lowercased() + return lowered.hasPrefix("query") || lowered.hasPrefix("mutation") || lowered.hasPrefix("subscription") + || lowered.hasPrefix("fragment") + } + + public static func requestBody(query: String) throws -> Data { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["query"] != nil { + return try WeaviateJSON.data(object) + } + return try WeaviateJSON.data(["query": trimmed]) + } +} + +public struct WeaviateConsoleRequest: Sendable, Equatable { + public let method: String + public let path: String + public let body: String? + + public init(method: String, path: String, body: String?) { + self.method = method + self.path = path + self.body = body + } +} + +public enum WeaviateConsoleParser { + public static func parse(_ input: String) -> WeaviateConsoleRequest? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard let newline = trimmed.firstIndex(where: \.isNewline) else { + return parseHeader(trimmed, body: nil) + } + let header = String(trimmed[.. WeaviateConsoleRequest? { + let parts = header.split(maxSplits: 2, omittingEmptySubsequences: true, whereSeparator: \.isWhitespace) + guard parts.count >= 2 else { return nil } + let method = String(parts[0]).uppercased() + guard ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].contains(method) else { + return nil + } + let rawPath = String(parts[1]) + guard rawPath.hasPrefix("/") else { return nil } + var path = rawPath + if path != "/", path != "/v1", !path.hasPrefix("/v1/"), !path.hasPrefix("/v1?") { + path = "/v1" + path + } + let inlineBody = parts.count > 2 + ? String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines) + : "" + return WeaviateConsoleRequest( + method: method, + path: path, + body: body ?? (inlineBody.isEmpty ? nil : inlineBody) + ) + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateJSON.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateJSON.swift new file mode 100644 index 0000000000..6ae697efdb --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateJSON.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum WeaviateJSON { + public static func dictionary(_ value: Any?) -> [String: Any]? { + value as? [String: Any] + } + + public static func data(_ object: Any, pretty: Bool = false) throws -> Data { + guard JSONSerialization.isValidJSONObject(object) else { + throw WeaviateError.malformedResponse(String(localized: "Request body is not valid JSON.")) + } + var options: JSONSerialization.WritingOptions = [.sortedKeys] + if pretty { + options.insert(.prettyPrinted) + } + return try JSONSerialization.data(withJSONObject: object, options: options) + } + + public static func text(_ object: Any, pretty: Bool = false) throws -> String { + let encoded = try data(object, pretty: pretty) + return String(data: encoded, encoding: .utf8) ?? "{}" + } + + public static func displayText(_ value: Any?) -> String? { + switch value { + case nil, is NSNull: + return nil + case let text as String: + return text + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + return number.stringValue + case let object as [String: Any]: + return (try? text(object)) ?? nil + case let object as [Any]: + return (try? text(object)) ?? nil + default: + return String(describing: value as Any) + } + } + + /// Only a property the grid renders as JSON is parsed back as JSON. Running the parser over + /// every type sends a `text` cell holding `{"a":1}` as an object, which Weaviate rejects while + /// the grid reports the save. + public static func parsedValue(_ text: String, typeName: String) -> Any { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let declared = typeName.trimmingCharacters(in: .whitespaces) + let isArray = declared.hasSuffix("[]") + if !isArray { + switch WeaviateValueKind.forDataType(declared) { + case .boolean: + if trimmed.lowercased() == "true" { return true } + if trimmed.lowercased() == "false" { return false } + return text + case .int: + return Int(trimmed) ?? text + case .number: + return Double(trimmed) ?? text + case .text, .uuid, .date: + break + } + } + let shape = WeaviatePropertyShape.of(WeaviateProperty(name: "", dataType: declared)) + guard isArray || shape != .scalar else { return text } + guard let data = trimmed.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data), + parsed is [Any] || parsed is [String: Any] + else { return text } + return parsed + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateObjectCodec.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateObjectCodec.swift new file mode 100644 index 0000000000..d202dfba72 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateObjectCodec.swift @@ -0,0 +1,141 @@ +import Foundation + +public struct WeaviateObject: Sendable, Equatable { + public let uuid: String + public let className: String + public let properties: [String: String?] + public let vector: [Double]? + + public init(uuid: String, className: String, properties: [String: String?], vector: [Double]?) { + self.uuid = uuid + self.className = className + self.properties = properties + self.vector = vector + } + + public static func parse(_ json: [String: Any]) -> WeaviateObject? { + let uuid = (json["id"] as? String) ?? "" + let className = (json["class"] as? String) ?? "" + let raw = json["properties"] as? [String: Any] ?? [:] + var properties: [String: String?] = [:] + for (key, value) in raw { + properties[key] = WeaviateJSON.displayText(value) + } + let vector = vector(from: json["vector"]) + if uuid.isEmpty, properties.isEmpty, vector == nil { + return nil + } + return WeaviateObject(uuid: uuid, className: className, properties: properties, vector: vector) + } + + public static func parseList(_ json: Any) -> [WeaviateObject] { + let objects: [[String: Any]] + if let object = json as? [String: Any] { + objects = object["objects"] as? [[String: Any]] ?? [] + } else if let array = json as? [[String: Any]] { + objects = array + } else { + return [] + } + return objects.compactMap(parse) + } + + public var vectorText: String? { + guard let vector, !vector.isEmpty else { + return vector == nil ? nil : "[]" + } + return "[" + vector.map(Self.numberText).joined(separator: ",") + "]" + } + + private static func numberText(_ value: Double) -> String { + if value.rounded() == value, let whole = Int(exactly: value) { + return String(whole) + } + return String(value) + } + + static func vector(from value: Any?) -> [Double]? { + if let numbers = value as? [Double] { + return numbers + } + if let numbers = value as? [NSNumber] { + return numbers.map(\.doubleValue) + } + return nil + } +} + +public enum WeaviateObjectCodec { + static let additionalKey = "_additional" + + public static func row(for object: WeaviateObject, columns: [String]) -> [String?] { + return columns.map { column in + switch column { + case WeaviateSchema.uuidColumn: + return object.uuid.isEmpty ? nil : object.uuid + case WeaviateSchema.vectorColumn: + return object.vectorText + default: + if let value = object.properties[column] { + return value + } + return nil + } + } + } + + public static func objects(fromGraphQL json: Any) -> [WeaviateObject] { + guard let root = json as? [String: Any] else { return [] } + if let errors = root["errors"] as? [[String: Any]], !errors.isEmpty { + return [] + } + guard let data = root["data"] as? [String: Any] else { return [] } + if let get = data["Get"] as? [String: Any] { + return objects(fromGet: get) + } + return [] + } + + public static func graphQLErrors(from json: Any) -> [String] { + guard let root = json as? [String: Any], + let errors = root["errors"] as? [[String: Any]] + else { return [] } + return errors.compactMap { $0["message"] as? String }.filter { !$0.isEmpty } + } + + private static func objects(fromGet get: [String: Any]) -> [WeaviateObject] { + var result: [WeaviateObject] = [] + for (className, value) in get { + guard let rows = value as? [[String: Any]] else { continue } + for row in rows { + result.append(object(fromGetRow: row, className: className)) + } + } + return result + } + + /// `_additional` is where a vector search puts `distance`, `score` and `certainty`, which are + /// the whole point of the query the user wrote. Only `id` and `vector` have a column of their + /// own; the rest become properties so they reach the grid. + private static func object(fromGetRow row: [String: Any], className: String) -> WeaviateObject { + var properties: [String: String?] = [:] + var additional: [String: Any] = [:] + var uuid = "" + var vector: [Double]? + for (key, value) in row { + if key == additionalKey, let fields = value as? [String: Any] { + additional = fields + continue + } + properties[key] = WeaviateJSON.displayText(value) + } + uuid = (additional["id"] as? String) ?? uuid + vector = WeaviateObject.vector(from: additional["vector"]) + for (key, value) in additional where key != "id" && key != WeaviateSchema.vectorColumn { + let name = properties[key] == nil ? key : "\(additionalKey).\(key)" + properties[name] = WeaviateJSON.displayText(value) + } + return WeaviateObject(uuid: uuid, className: className, properties: properties, vector: vector) + } +} + diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviatePathEncoding.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviatePathEncoding.swift new file mode 100644 index 0000000000..0d6ffcd3c4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviatePathEncoding.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum WeaviatePathEncoding { + private static let allowed: CharacterSet = { + var set = CharacterSet.alphanumerics + set.insert(charactersIn: "-_") + return set + }() + + public static func segment(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + /// A console line carries its own query string (`GET /v1/objects?class=Article`), so the path + /// is parsed rather than assigned whole: `URLComponents.path` percent-encodes `?` into `%3F` + /// and sends the request to a path that does not exist. + public static func resolve(_ path: String, query: [String: String] = [:], against base: URL) -> URL? { + guard path.hasPrefix("/"), !path.contains("://") else { return nil } + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false), + let requested = URLComponents(string: path) + else { return nil } + components.percentEncodedPath = requested.percentEncodedPath + var items = requested.queryItems ?? [] + items += query.keys.sorted().map { URLQueryItem(name: $0, value: query[$0]) } + components.queryItems = items.isEmpty ? nil : items + return components.url + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateQuery.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateQuery.swift new file mode 100644 index 0000000000..773b8cf34d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateQuery.swift @@ -0,0 +1,360 @@ +import Foundation + +public struct WeaviateFilterSpec: Codable, Sendable, Equatable { + public let column: String + public let op: String + public let value: String + + /// The upper bound of `BETWEEN`, carried apart from `value` so a value holding a comma cannot + /// be mistaken for the separator between the two bounds. + public let secondValue: String? + + public init(column: String, op: String, value: String, secondValue: String? = nil) { + self.column = column + self.op = op + self.value = value + self.secondValue = secondValue + } +} + +public struct WeaviateSortSpec: Codable, Sendable, Equatable { + public let column: String + public let ascending: Bool + + public init(column: String, ascending: Bool) { + self.column = column + self.ascending = ascending + } +} + +public struct WeaviateParsedSearch: Sendable, Equatable { + public let collection: String + public let offset: Int + public let limit: Int + public let sorts: [WeaviateSortSpec] + public let filters: [WeaviateFilterSpec] + public let logicMode: String + public let propertyNames: [String] + + public init( + collection: String, + offset: Int, + limit: Int, + sorts: [WeaviateSortSpec], + filters: [WeaviateFilterSpec], + logicMode: String, + propertyNames: [String] + ) { + self.collection = collection + self.offset = offset + self.limit = limit + self.sorts = sorts + self.filters = filters + self.logicMode = logicMode + self.propertyNames = propertyNames + } + + /// `GET /v1/objects` sorts, but only on a property: the grid's `vector` column is not one, and + /// Weaviate answers `no such prop with name 'vector'`. Everything else goes through GraphQL, + /// whose `sort` argument takes the object id as well. + public var sortableSorts: [WeaviateSortSpec] { + sorts.filter { $0.column != WeaviateSchema.vectorColumn } + } + + public var usesGraphQL: Bool { + !filters.isEmpty || !sortableSorts.isEmpty + } +} + +public enum WeaviateBrowseQuery { + public static let searchTag = "WEAVIATE_SEARCH:" + + public static func encode( + collection: String, + offset: Int, + limit: Int, + sorts: [WeaviateSortSpec], + filters: [WeaviateFilterSpec], + logicMode: String, + propertyNames: [String] + ) -> String { + let payload: [String: Any] = [ + "collection": collection, + "offset": offset, + "limit": limit, + "logicMode": logicMode, + "sorts": sorts.map { ["column": $0.column, "ascending": $0.ascending] }, + "filters": filters.map { filter -> [String: Any] in + var encoded: [String: Any] = ["column": filter.column, "op": filter.op, "value": filter.value] + if let secondValue = filter.secondValue { + encoded["secondValue"] = secondValue + } + return encoded + }, + "properties": propertyNames + ] + let body = (try? WeaviateJSON.data(payload)) ?? Data() + return searchTag + body.base64EncodedString() + } + + public static func parse(_ query: String) -> WeaviateParsedSearch? { + guard query.hasPrefix(searchTag) else { return nil } + let encoded = String(query.dropFirst(searchTag.count)) + guard let data = Data(base64Encoded: encoded), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let collection = json["collection"] as? String + else { return nil } + let sortsJSON = json["sorts"] as? [[String: Any]] ?? [] + let filtersJSON = json["filters"] as? [[String: Any]] ?? [] + let properties = json["properties"] as? [String] ?? [] + return WeaviateParsedSearch( + collection: collection, + offset: json["offset"] as? Int ?? 0, + limit: json["limit"] as? Int ?? 25, + sorts: sortsJSON.compactMap { item in + guard let column = item["column"] as? String else { return nil } + return WeaviateSortSpec(column: column, ascending: item["ascending"] as? Bool ?? true) + }, + filters: filtersJSON.compactMap { item in + guard let column = item["column"] as? String, let op = item["op"] as? String else { + return nil + } + return WeaviateFilterSpec( + column: column, + op: op, + value: item["value"] as? String ?? "", + secondValue: item["secondValue"] as? String + ) + }, + logicMode: json["logicMode"] as? String ?? "AND", + propertyNames: properties + ) + } + + public static func isTagged(_ query: String) -> Bool { + query.hasPrefix(searchTag) + } +} + +public struct WeaviateWriteRequest: Sendable, Equatable { + public let method: String + public let path: String + public let query: [String: String] + public let body: String? + + public init(method: String, path: String, query: [String: String] = [:], body: String?) { + self.method = method + self.path = path + self.query = query + self.body = body + } +} + +public enum WeaviateWriteCodec { + public static let writeTag = "WEAVIATE_WRITE:" + + public static func encode(_ request: WeaviateWriteRequest) -> String { + let payload: [String: Any] = [ + "method": request.method, + "path": request.path, + "query": request.query, + "body": request.body ?? "" + ] + let data = (try? WeaviateJSON.data(payload)) ?? Data() + return writeTag + data.base64EncodedString() + } + + public static func decode(_ statement: String) -> WeaviateWriteRequest? { + guard statement.hasPrefix(writeTag) else { return nil } + let encoded = String(statement.dropFirst(writeTag.count)) + guard let data = Data(base64Encoded: encoded), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let method = json["method"] as? String, + let path = json["path"] as? String + else { return nil } + let query = json["query"] as? [String: String] ?? [:] + let body = json["body"] as? String + return WeaviateWriteRequest( + method: method, + path: path, + query: query, + body: (body?.isEmpty ?? true) ? nil : body + ) + } + + public static func isTagged(_ statement: String) -> Bool { + statement.hasPrefix(writeTag) + } +} + +public struct WeaviateCellChange: Sendable, Equatable { + public let column: String + public let newText: String? + + public init(column: String, newText: String?) { + self.column = column + self.newText = newText + } +} + +public struct WeaviateTrackedChange: Sendable, Equatable { + public enum Kind: String, Sendable, Equatable { + case insert + case update + case delete + } + + public let kind: Kind + public let uuid: String? + public let values: [String: String?] + public let cellChanges: [WeaviateCellChange] + + public init( + kind: Kind, + uuid: String?, + values: [String: String?], + cellChanges: [WeaviateCellChange] + ) { + self.kind = kind + self.uuid = uuid + self.values = values + self.cellChanges = cellChanges + } +} + +public enum WeaviateSkipReason: String, Sendable, Equatable { + case missingUUID + case noEditableColumns + case payloadNotEncodable +} + +public struct WeaviateSkippedChange: Sendable, Equatable { + public let kind: WeaviateTrackedChange.Kind + public let reason: WeaviateSkipReason + + public init(kind: WeaviateTrackedChange.Kind, reason: WeaviateSkipReason) { + self.kind = kind + self.reason = reason + } +} + +/// A skipped change writes nothing while the grid reports the save succeeded, so the driver has to +/// be able to say what it dropped. Same reason the MongoDB generator logs its own skips. +public struct WeaviateWriteBatch: Sendable, Equatable { + public let requests: [WeaviateWriteRequest] + public let skipped: [WeaviateSkippedChange] + + public init(requests: [WeaviateWriteRequest], skipped: [WeaviateSkippedChange]) { + self.requests = requests + self.skipped = skipped + } +} + +public enum WeaviateStatementGenerator { + public static func generate( + collection: String, + columns: [String], + typeNames: [String], + changes: [WeaviateTrackedChange] + ) -> WeaviateWriteBatch { + let types = Dictionary(zip(columns, typeNames), uniquingKeysWith: { first, _ in first }) + var requests: [WeaviateWriteRequest] = [] + var skipped: [WeaviateSkippedChange] = [] + for change in changes { + let request: WeaviateWriteRequest? + switch change.kind { + case .insert: + request = insert(collection: collection, types: types, change: change) + case .update: + request = update(collection: collection, types: types, change: change) + case .delete: + request = delete(collection: collection, change: change) + } + if let request { + requests.append(request) + } else { + skipped.append(WeaviateSkippedChange(kind: change.kind, reason: reason(for: change))) + } + } + return WeaviateWriteBatch(requests: requests, skipped: skipped) + } + + private static func reason(for change: WeaviateTrackedChange) -> WeaviateSkipReason { + if change.kind != .insert, change.uuid?.isEmpty ?? true { + return .missingUUID + } + if change.kind == .update, editablePatch(from: change).isEmpty { + return .noEditableColumns + } + return .payloadNotEncodable + } + + private static func editablePatch(from change: WeaviateTrackedChange) -> [String: String?] { + var patch: [String: String?] = [:] + for cell in change.cellChanges where !WeaviateSchema.immutableColumns.contains(cell.column) { + patch[cell.column] = cell.newText + } + return patch + } + + private static func insert( + collection: String, + types: [String: String], + change: WeaviateTrackedChange + ) -> WeaviateWriteRequest? { + var payload: [String: Any] = [ + "class": collection, + "properties": properties(from: change.values, types: types) + ] + if let uuid = change.uuid, !uuid.isEmpty { + payload["id"] = uuid + } + guard let body = try? WeaviateJSON.text(payload) else { return nil } + return WeaviateWriteRequest(method: "POST", path: "/v1/objects", body: body) + } + + private static func update( + collection: String, + types: [String: String], + change: WeaviateTrackedChange + ) -> WeaviateWriteRequest? { + guard let uuid = change.uuid, !uuid.isEmpty else { return nil } + let patch = editablePatch(from: change) + guard !patch.isEmpty else { return nil } + let payload: [String: Any] = [ + "class": collection, + "properties": properties(from: patch, types: types) + ] + guard let body = try? WeaviateJSON.text(payload) else { return nil } + return WeaviateWriteRequest( + method: "PATCH", + path: "/v1/objects/\(WeaviatePathEncoding.segment(uuid))", + query: ["class": collection], + body: body + ) + } + + private static func delete(collection: String, change: WeaviateTrackedChange) -> WeaviateWriteRequest? { + guard let uuid = change.uuid, !uuid.isEmpty else { return nil } + return WeaviateWriteRequest( + method: "DELETE", + path: "/v1/objects/\(WeaviatePathEncoding.segment(uuid))", + query: ["class": collection], + body: nil + ) + } + + private static func properties(from values: [String: String?], types: [String: String]) -> [String: Any] { + var result: [String: Any] = [:] + for (column, text) in values { + if WeaviateSchema.immutableColumns.contains(column) { continue } + if column.hasPrefix("\(WeaviateObjectCodec.additionalKey).") { continue } + if let text { + result[column] = WeaviateJSON.parsedValue(text, typeName: types[column] ?? "text") + } else { + result[column] = NSNull() + } + } + return result + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateSchema.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateSchema.swift new file mode 100644 index 0000000000..b1478e6ec8 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateSchema.swift @@ -0,0 +1,119 @@ +import Foundation + +public struct WeaviateProperty: Sendable, Equatable { + public let name: String + public let dataTypes: [String] + public let nestedProperties: [WeaviateProperty] + + public var dataType: String { dataTypes.first ?? "text" } + + public init(name: String, dataType: String) { + self.init(name: name, dataTypes: [dataType], nestedProperties: []) + } + + public init(name: String, dataTypes: [String], nestedProperties: [WeaviateProperty]) { + self.name = name + self.dataTypes = dataTypes.isEmpty ? ["text"] : dataTypes + self.nestedProperties = nestedProperties + } + + public static func parse(_ json: [String: Any]) -> WeaviateProperty? { + guard let name = json["name"] as? String, !name.isEmpty else { return nil } + let nested = json["nestedProperties"] as? [[String: Any]] ?? [] + return WeaviateProperty( + name: name, + dataTypes: json["dataType"] as? [String] ?? [], + nestedProperties: nested.compactMap(WeaviateProperty.parse) + ) + } +} + +/// A GraphQL field with a structured type is refused without a sub-selection: asking for a +/// `geoCoordinates` property by name answers `Field "place" ... must have a sub selection` and the +/// whole query fails, taking the filtered browse with it. +public enum WeaviatePropertyShape: Sendable, Equatable { + case scalar + case geoCoordinates + case phoneNumber + case object + case crossReference([String]) + + private static let scalarTypes: Set = [ + "text", "string", "int", "number", "boolean", "bool", "date", "uuid", "blob" + ] + + public static func of(_ property: WeaviateProperty) -> WeaviatePropertyShape { + let names = property.dataTypes.map { element($0) } + guard let first = names.first else { return .scalar } + if scalarTypes.contains(first.lowercased()) { return .scalar } + switch first { + case "geoCoordinates": return .geoCoordinates + case "phoneNumber": return .phoneNumber + case "object": return .object + default: + return first.first?.isUppercase == true ? .crossReference(names) : .scalar + } + } + + private static func element(_ dataType: String) -> String { + var name = dataType.trimmingCharacters(in: .whitespaces) + while name.hasSuffix("[]") { + name = String(name.dropLast(2)) + } + return name + } +} + +public struct WeaviateCollection: Sendable, Equatable { + public let name: String + public let properties: [WeaviateProperty] + public let vectorizer: String? + + public init(name: String, properties: [WeaviateProperty], vectorizer: String? = nil) { + self.name = name + self.properties = properties + self.vectorizer = vectorizer + } + + public static func parse(_ json: [String: Any]) -> WeaviateCollection? { + guard let name = json["class"] as? String, !name.isEmpty else { return nil } + let rawProperties = json["properties"] as? [[String: Any]] ?? [] + let properties = rawProperties.compactMap(WeaviateProperty.parse) + return WeaviateCollection( + name: name, + properties: properties, + vectorizer: json["vectorizer"] as? String + ) + } +} + +public enum WeaviateSchema { + public static let uuidColumn = "uuid" + public static let vectorColumn = "vector" + public static let immutableColumns: [String] = [uuidColumn, vectorColumn] + + /// Weaviate names the object id `id` inside a `where` or `sort` argument, and normalizes it to + /// `_id` in its own errors. The grid calls the same column `uuid`. + public static let uuidGraphQLPath = "id" + + public static func collections(from json: Any) -> [WeaviateCollection] { + let classes: [[String: Any]] + if let object = json as? [String: Any] { + classes = object["classes"] as? [[String: Any]] ?? [] + } else if let array = json as? [[String: Any]] { + classes = array + } else { + return [] + } + return classes.compactMap(WeaviateCollection.parse) + } + + public static func columns(for collection: WeaviateCollection) -> [(name: String, type: String, isPrimaryKey: Bool)] { + var result: [(name: String, type: String, isPrimaryKey: Bool)] = [ + (uuidColumn, "uuid", true) + ] + result += collection.properties.map { ($0.name, $0.dataType, false) } + result.append((vectorColumn, "vector", false)) + return result + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/Wire/WeaviateClient.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/Wire/WeaviateClient.swift new file mode 100644 index 0000000000..cb56e8146b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/Wire/WeaviateClient.swift @@ -0,0 +1,140 @@ +import Foundation + +public final class WeaviateClient: @unchecked Sendable { + public let settings: WeaviateConnectionSettings + private let transport: WeaviateTransport + private let timeout: @Sendable () -> TimeInterval + private let lock = NSLock() + private var _version: String? + + public init( + settings: WeaviateConnectionSettings, + transport: WeaviateTransport, + timeout: @escaping @Sendable () -> TimeInterval + ) { + self.settings = settings + self.transport = transport + self.timeout = timeout + } + + public var serverVersion: String? { + lock.withLock { _version } + } + + public func cancelAll() { + transport.cancelAll() + } + + public func connect() async throws { + let ready = try await send(method: "GET", path: "/v1/.well-known/ready") + try throwIfFailed(ready) + let meta = try await send(method: "GET", path: "/v1/meta") + try throwIfFailed(meta) + if let json = WeaviateJSON.dictionary(meta.json), let version = json["version"] as? String { + lock.withLock { _version = version } + } + } + + /// `/v1/.well-known/ready` is the readiness probe and answers 200 with no key at all, so a + /// revoked key would leave the health monitor reporting a session every query then fails on. + public func ping() async throws { + let response = try await send(method: "GET", path: "/v1/meta") + try throwIfFailed(response) + } + + public func schema() async throws -> [WeaviateCollection] { + let response = try await send(method: "GET", path: "/v1/schema") + try throwIfFailed(response) + guard let json = response.json else { + throw WeaviateError.malformedResponse(String(localized: "Schema response was empty.")) + } + return WeaviateSchema.collections(from: json) + } + + public func objects( + collection: String, + limit: Int, + offset: Int, + includeVector: Bool = true + ) async throws -> [WeaviateObject] { + var query = [ + "class": collection, + "limit": String(max(limit, 0)), + "offset": String(max(offset, 0)) + ] + if includeVector { + query["include"] = "vector" + } + let response = try await send(method: "GET", path: "/v1/objects", query: query) + try throwIfFailed(response) + guard let json = response.json else { return [] } + return WeaviateObject.parseList(json) + } + + public func graphql(_ query: String) async throws -> WeaviateHTTPResponse { + let body = try WeaviateGraphQL.requestBody(query: query) + let response = try await send(method: "POST", path: "/v1/graphql", body: body) + try throwIfFailed(response) + if let json = response.json { + let errors = WeaviateObjectCodec.graphQLErrors(from: json) + if !errors.isEmpty { + throw WeaviateError.api(status: response.statusCode, message: errors.joined(separator: "\n")) + } + } + return response + } + + public func execute(write request: WeaviateWriteRequest) async throws -> WeaviateHTTPResponse { + let body = request.body.flatMap { $0.data(using: .utf8) } + let response = try await send( + method: request.method, + path: request.path, + query: request.query, + body: body + ) + try throwIfFailed(response) + return response + } + + public func execute(console request: WeaviateConsoleRequest) async throws -> WeaviateHTTPResponse { + let body = request.body.flatMap { $0.data(using: .utf8) } + let response = try await send(method: request.method, path: request.path, body: body) + try throwIfFailed(response) + return response + } + + public func send( + method: String, + path: String, + query: [String: String] = [:], + body: Data? = nil + ) async throws -> WeaviateHTTPResponse { + let base = try settings.baseURL() + guard let url = WeaviatePathEncoding.resolve(path, query: query, against: base) else { + throw WeaviateError.configuration(String(format: String(localized: "Invalid path: %@"), path)) + } + var headers = [ + "Accept": "application/json" + ] + if body != nil { + headers["Content-Type"] = "application/json" + } + if let authorization = settings.auth.authorizationHeader { + headers["Authorization"] = authorization + } + let request = WeaviateHTTPRequest( + method: method, + url: url, + headers: headers, + body: body, + timeoutInterval: timeout() + ) + return try await transport.send(request) + } + + public func throwIfFailed(_ response: WeaviateHTTPResponse) throws { + guard (200..<300).contains(response.statusCode) else { + throw WeaviateError.from(status: response.statusCode, body: response.body) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/Wire/WeaviateTransport.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/Wire/WeaviateTransport.swift new file mode 100644 index 0000000000..f7695710e2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/Wire/WeaviateTransport.swift @@ -0,0 +1,174 @@ +import Foundation + +public struct WeaviateHTTPRequest: Sendable, Equatable { + public let method: String + public let url: URL + public let headers: [String: String] + public let body: Data? + public let timeoutInterval: TimeInterval + + public init( + method: String, + url: URL, + headers: [String: String], + body: Data?, + timeoutInterval: TimeInterval + ) { + self.method = method + self.url = url + self.headers = headers + self.body = body + self.timeoutInterval = timeoutInterval + } +} + +public struct WeaviateHTTPResponse: Sendable, Equatable { + public let statusCode: Int + public let body: Data + private let parsed: ParsedResponseJSON + + public init(statusCode: Int, body: Data) { + self.statusCode = statusCode + self.body = body + self.parsed = ParsedResponseJSON(body) + } + + /// A filtered browse reads this two or three times, and a page of 1536-dimension vectors is + /// several megabytes, so the body is parsed once and the result held. + public var json: Any? { + parsed.value + } + + public var text: String { + String(data: body, encoding: .utf8) ?? "" + } + + public static func == (lhs: WeaviateHTTPResponse, rhs: WeaviateHTTPResponse) -> Bool { + lhs.statusCode == rhs.statusCode && lhs.body == rhs.body + } +} + +private final class ParsedResponseJSON: @unchecked Sendable { + private let body: Data + private let lock = NSLock() + private var value_: Any? + private var hasParsed = false + + init(_ body: Data) { + self.body = body + } + + var value: Any? { + lock.withLock { + if !hasParsed { + value_ = try? JSONSerialization.jsonObject(with: body, options: [.fragmentsAllowed]) + hasParsed = true + } + return value_ + } + } +} + +public protocol WeaviateTransport: Sendable { + func send(_ request: WeaviateHTTPRequest) async throws -> WeaviateHTTPResponse + func cancelAll() +} + +public final class URLSessionWeaviateTransport: WeaviateTransport, @unchecked Sendable { + private let session: URLSession + private let lock = NSLock() + private var inFlight: [ObjectIdentifier: URLSessionTask] = [:] + + public init( + configuration: URLSessionConfiguration = .ephemeral, + resourceTimeout: TimeInterval, + skipTLSVerify: Bool = false + ) { + configuration.timeoutIntervalForResource = resourceTimeout + if skipTLSVerify { + let delegate = InsecureTLSDelegate() + session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + } else { + session = URLSession(configuration: configuration) + } + } + + deinit { + session.invalidateAndCancel() + } + + public func cancelAll() { + let tasks = lock.withLock { Array(inFlight.values) } + tasks.forEach { $0.cancel() } + } + + public func send(_ request: WeaviateHTTPRequest) async throws -> WeaviateHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = request.timeoutInterval + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + + let tracker = TaskTracker(transport: self) + defer { tracker.finish() } + do { + let (data, response) = try await session.data(for: urlRequest, delegate: tracker) + guard let httpResponse = response as? HTTPURLResponse else { + throw WeaviateError.transport(String(localized: "Weaviate answered with something other than HTTP.")) + } + return WeaviateHTTPResponse(statusCode: httpResponse.statusCode, body: data) + } catch let error as URLError where error.code == .cancelled { + throw WeaviateError.cancelled + } catch let error as WeaviateError { + throw error + } catch let error as URLError { + throw WeaviateError.transport(error.localizedDescription) + } + } + + fileprivate func register(_ task: URLSessionTask) { + lock.withLock { inFlight[ObjectIdentifier(task)] = task } + } + + fileprivate func unregister(_ task: URLSessionTask) { + lock.withLock { inFlight[ObjectIdentifier(task)] = nil } + } +} + +private final class InsecureTLSDelegate: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + } +} + +private final class TaskTracker: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private weak var transport: URLSessionWeaviateTransport? + private let lock = NSLock() + private var task: URLSessionTask? + + init(transport: URLSessionWeaviateTransport) { + self.transport = transport + } + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + lock.withLock { self.task = task } + transport?.register(task) + } + + func finish() { + guard let task = lock.withLock({ task }) else { return } + transport?.unregister(task) + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLParameterBatchTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLParameterBatchTests.swift new file mode 100644 index 0000000000..f0eee1e56b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLParameterBatchTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import TableProMSSQLCore + +@Suite("MSSQL parameter batch") +struct MSSQLParameterBatchTests { + /// Every parameter used to be declared `NVARCHAR(MAX)` and assigned from the value's text, and + /// a binary value has none: it was sent as `NULL`, so a row matched on a `VARBINARY` column + /// found nothing and the update or delete reported success having touched no row. + @Test("A binary parameter is declared and sent as binary") + func binaryParameterKeepsItsBytes() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "DELETE FROM [t] WHERE [payload] = ?", + parameters: [.bytes(Data([0xDE, 0xAD, 0xBE, 0xEF]))] + ) + #expect(statement.query == "DELETE FROM [t] WHERE [payload] = @p1") + #expect(statement.declarations == "@p1 VARBINARY(MAX)") + #expect(statement.assignments == "@p1 = 0xDEADBEEF") + } + + @Test("An empty binary is still binary") + func emptyBinaryIsStillBinary() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT ?", parameters: [.bytes(Data())] + ) + #expect(statement.declarations == "@p1 VARBINARY(MAX)") + #expect(statement.assignments == "@p1 = 0x") + } + + @Test("Text is an nvarchar literal and null stays null") + func textAndNullKeepTheirTypes() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT ?, ?", parameters: [.text("O'Brien"), .null] + ) + #expect(statement.declarations == "@p1 NVARCHAR(MAX), @p2 NVARCHAR(MAX)") + #expect(statement.assignments == "@p1 = N'O''Brien', @p2 = NULL") + } + + /// Non-Latin text in a parameter is the same problem the literals have: a plain `'…'` is a + /// `varchar` and a non-Unicode collation turns it into question marks. + @Test("Text parameters carry the N prefix") + func textParametersAreNvarcharLiterals() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT ?", parameters: [.text("日本語")] + ) + #expect(statement.assignments == "@p1 = N'日本語'") + } + + @Test("Each parameter is declared as the type its own value is") + func typesArePerParameter() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT ?, ?, ?", + parameters: [.text("a"), .bytes(Data([0x01])), .null] + ) + #expect(statement.declarations == "@p1 NVARCHAR(MAX), @p2 VARBINARY(MAX), @p3 NVARCHAR(MAX)") + #expect(statement.assignments == "@p1 = N'a', @p2 = 0x01, @p3 = NULL") + } + + /// A `?` inside a literal or a quoted identifier is data. Reading one as a placeholder shifts + /// every parameter after it by one. + @Test("A question mark inside a literal or an identifier is not a placeholder") + func questionMarksInsideLiteralsAreData() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT '?', [we?ird], ? FROM [t] WHERE [a] = ?", + parameters: [.text("x"), .text("y")] + ) + #expect(statement.query == "SELECT '?', [we?ird], @p1 FROM [t] WHERE [a] = @p2") + #expect(statement.declarations == "@p1 NVARCHAR(MAX), @p2 NVARCHAR(MAX)") + } + + @Test("A doubled closing bracket does not end an identifier") + func doubledBracketsStayInsideTheIdentifier() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT [a]]?b], ? FROM [t]", parameters: [.text("x")] + ) + #expect(statement.query == "SELECT [a]]?b], @p1 FROM [t]") + } + + @Test("A doubled quote inside a literal does not end it") + func doubledQuotesStayInsideTheLiteral() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT 'it''s ?', ?", parameters: [.text("x")] + ) + #expect(statement.query == "SELECT 'it''s ?', @p1") + } + + @Test("A query with no placeholder declares nothing") + func noPlaceholdersDeclaresNothing() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT 1", parameters: [.text("unused")] + ) + #expect(statement.isEmpty) + #expect(statement.query == "SELECT 1") + } + + @Test("More placeholders than parameters leaves the extra ones alone") + func extraPlaceholdersAreLeftAlone() { + let statement = MSSQLParameterBatch.spExecuteSql( + query: "SELECT ?, ?", parameters: [.text("a")] + ) + #expect(statement.query == "SELECT @p1, ?") + #expect(statement.declarations == "@p1 NVARCHAR(MAX)") + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift index 43c552ccba..6063679359 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift @@ -18,7 +18,7 @@ final class MSSQLSchemaQueriesTests: XCTestCase { func testTablesQueryEscapesSchema() { let sql = MSSQLSchemaQueries.tables(schema: "O'Brien") - XCTAssertTrue(sql.contains("'O''Brien'")) + XCTAssertTrue(sql.contains("N'O''Brien'")) XCTAssertTrue(sql.contains("INFORMATION_SCHEMA.TABLES")) XCTAssertTrue(sql.contains("'BASE TABLE'")) XCTAssertTrue(sql.contains("'VIEW'")) @@ -28,21 +28,21 @@ final class MSSQLSchemaQueriesTests: XCTestCase { let sql = MSSQLSchemaQueries.columns(schema: "dbo", table: "Users") XCTAssertTrue(sql.contains("IsIdentity")) XCTAssertTrue(sql.contains("PRIMARY KEY")) - XCTAssertTrue(sql.contains("'Users'")) - XCTAssertTrue(sql.contains("'dbo'")) + XCTAssertTrue(sql.contains("N'Users'")) + XCTAssertTrue(sql.contains("N'dbo'")) } func testIndexesQueryUsesBracketedIdentifier() { let sql = MSSQLSchemaQueries.indexes(schema: "dbo", table: "Users") - XCTAssertTrue(sql.contains("OBJECT_ID('[dbo].[Users]')")) + XCTAssertTrue(sql.contains("OBJECT_ID(N'[dbo].[Users]')")) XCTAssertTrue(sql.contains("sys.indexes")) } func testForeignKeysQueryFiltersByTableAndSchema() { let sql = MSSQLSchemaQueries.foreignKeys(schema: "dbo", table: "Orders") XCTAssertTrue(sql.contains("sys.foreign_keys")) - XCTAssertTrue(sql.contains("'Orders'")) - XCTAssertTrue(sql.contains("'dbo'")) + XCTAssertTrue(sql.contains("N'Orders'")) + XCTAssertTrue(sql.contains("N'dbo'")) } func testForeignKeysQuerySelectsReferencedSchema() { @@ -175,4 +175,24 @@ final class MSSQLSchemaQueriesTests: XCTestCase { "SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY" ) } + + func testCatalogPredicatesSurviveANonAsciiName() { + let columns = MSSQLSchemaQueries.columns(schema: "dbo", table: "顧客テーブル") + XCTAssertTrue(columns.contains("N'顧客テーブル'")) + XCTAssertFalse(columns.contains("= '顧客テーブル'")) + + let indexes = MSSQLSchemaQueries.indexes(schema: "dbo", table: "顧客テーブル") + XCTAssertTrue(indexes.contains("OBJECT_ID(N'[dbo].[顧客テーブル]')")) + + let foreignKeys = MSSQLSchemaQueries.foreignKeys(schema: "スキーマ", table: "顧客テーブル") + XCTAssertTrue(foreignKeys.contains("N'顧客テーブル'")) + XCTAssertTrue(foreignKeys.contains("N'スキーマ'")) + } + + func testFixedCatalogConstantsStayUnprefixed() { + let tables = MSSQLSchemaQueries.tables(schema: "dbo") + XCTAssertTrue(tables.contains("IN ('BASE TABLE', 'VIEW')")) + XCTAssertTrue(MSSQLSchemaQueries.columns(schema: "dbo", table: "t").contains("= 'PRIMARY KEY'")) + XCTAssertTrue(MSSQLSchemaQueries.schemas.contains("'information_schema'")) + } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLStringLiteralTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLStringLiteralTests.swift new file mode 100644 index 0000000000..e8d0a1aa53 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLStringLiteralTests.swift @@ -0,0 +1,97 @@ +@testable import TableProMSSQLCore +import XCTest + +final class MSSQLStringLiteralTests: XCTestCase { + func testQuotedPrefixesWithN() { + XCTAssertEqual(MSSQLStringLiteral.quoted("plain"), "N'plain'") + } + + func testQuotedKeepsNonAsciiText() { + XCTAssertEqual(MSSQLStringLiteral.quoted("日本語メール"), "N'日本語メール'") + } + + func testQuotedDoublesSingleQuotes() { + XCTAssertEqual(MSSQLStringLiteral.quoted("O'Brien"), "N'O''Brien'") + XCTAssertEqual(MSSQLStringLiteral.quoted("'; DROP TABLE t --"), "N'''; DROP TABLE t --'") + } + + func testQuotedHandlesEmptyString() { + XCTAssertEqual(MSSQLStringLiteral.quoted(""), "N''") + } + + func testEscapedReturnsBodyWithoutQuotes() { + XCTAssertEqual(MSSQLStringLiteral.escaped("O'Brien"), "O''Brien") + XCTAssertEqual(MSSQLStringLiteral.escaped("plain"), "plain") + } + + func testLikePatternWrapsWildcardsInsideTheLiteral() { + XCTAssertEqual( + MSSQLStringLiteral.likePattern("メール", prefixWildcard: true, suffixWildcard: true), + "N'%メール%' ESCAPE '\\'" + ) + XCTAssertEqual( + MSSQLStringLiteral.likePattern("abc", prefixWildcard: false, suffixWildcard: true), + "N'abc%' ESCAPE '\\'" + ) + XCTAssertEqual( + MSSQLStringLiteral.likePattern("abc", prefixWildcard: true, suffixWildcard: false), + "N'%abc' ESCAPE '\\'" + ) + } + + func testLikePatternEscapesUserWildcards() { + XCTAssertEqual( + MSSQLStringLiteral.likePattern("50%_off", prefixWildcard: true, suffixWildcard: true), + "N'%50\\%\\_off%' ESCAPE '\\'" + ) + } + + /// `[` opens a character class in T-SQL and in no other engine this app speaks to, so a + /// filter for `a[bc]` matched `ab` and `ac` and never the text the user typed. + func testLikePatternEscapesBracketWildcards() { + XCTAssertEqual( + MSSQLStringLiteral.likePattern("a[bc]", prefixWildcard: true, suffixWildcard: true), + "N'%a\\[bc]%' ESCAPE '\\'" + ) + } + + func testLikePatternEscapesBackslashBeforeWildcards() { + XCTAssertEqual( + MSSQLStringLiteral.likePattern("a\\b", prefixWildcard: false, suffixWildcard: false), + "N'a\\\\b' ESCAPE '\\'" + ) + } + + func testLikePatternDoublesSingleQuotes() { + XCTAssertEqual( + MSSQLStringLiteral.likePattern("O'Brien", prefixWildcard: true, suffixWildcard: true), + "N'%O''Brien%' ESCAPE '\\'" + ) + } + + func testLikeConditionCoversTheFourPatternOperators() { + XCTAssertEqual( + MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "CONTAINS", value: "日本"), + "[n] LIKE N'%日本%' ESCAPE '\\'" + ) + XCTAssertEqual( + MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "NOT CONTAINS", value: "日本"), + "[n] NOT LIKE N'%日本%' ESCAPE '\\'" + ) + XCTAssertEqual( + MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "STARTS WITH", value: "日本"), + "[n] LIKE N'日本%' ESCAPE '\\'" + ) + XCTAssertEqual( + MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "ENDS WITH", value: "日本"), + "[n] LIKE N'%日本' ESCAPE '\\'" + ) + } + + func testLikeConditionLeavesOtherOperatorsToTheSharedBuilder() { + XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "=", value: "x")) + XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "IN", value: "a,b")) + XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "IS NULL", value: "")) + XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "REGEX", value: "x")) + } +} diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index f0d7b2e80f..26245ef7e2 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -10,6 +10,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.mariadb.rawValue == "MariaDB") #expect(DatabaseType.tidb.rawValue == "TiDB") #expect(DatabaseType.databend.rawValue == "Databend") + #expect(DatabaseType.oceanbase.rawValue == "OceanBase") #expect(DatabaseType.postgresql.rawValue == "PostgreSQL") #expect(DatabaseType.sqlite.rawValue == "SQLite") #expect(DatabaseType.redis.rawValue == "Redis") @@ -29,6 +30,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.mariadb.pluginTypeId == "MySQL") #expect(DatabaseType.tidb.pluginTypeId == "MySQL") #expect(DatabaseType.databend.pluginTypeId == "MySQL") + #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") #expect(DatabaseType.postgresql.pluginTypeId == "PostgreSQL") #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") #expect(DatabaseType.sqlite.pluginTypeId == "SQLite") @@ -59,10 +61,11 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 28) + #expect(DatabaseType.allKnownTypes.count == 30) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.tidb)) #expect(DatabaseType.allKnownTypes.contains(.databend)) + #expect(DatabaseType.allKnownTypes.contains(.oceanbase)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.spanner)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) @@ -74,6 +77,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.allKnownTypes.contains(.dameng)) #expect(DatabaseType.allKnownTypes.contains(.kafka)) #expect(DatabaseType.allKnownTypes.contains(.cloudflareR2SQL)) + #expect(DatabaseType.allKnownTypes.contains(.weaviate)) } /// The list has no duplicates, which a count alone would not catch: adding a type twice @@ -91,6 +95,13 @@ struct DatabaseTypeTests { #expect(DatabaseType.cloudflareR2SQL.pluginTypeId == "Cloudflare R2 SQL") } + @Test("Weaviate resolves its icon and plugin type id") + func weaviateIdentity() { + #expect(DatabaseType.weaviate.rawValue == "Weaviate") + #expect(DatabaseType.weaviate.iconName == "weaviate-icon") + #expect(DatabaseType.weaviate.pluginTypeId == "Weaviate") + } + @Test("Hashable conformance") func hashableConformance() { var set: Set = [.mysql, .postgresql, .mysql] diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateAuthAndQueryTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateAuthAndQueryTests.swift new file mode 100644 index 0000000000..e11c48cd81 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateAuthAndQueryTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Testing +@testable import TableProWeaviateCore + +@Suite("Weaviate auth and settings") +struct WeaviateAuthTests { + @Test("Field ids stay Weaviate-prefixed") + func fieldIdsArePrefixed() { + #expect(WeaviateFieldID.authMethod == "wvAuthMethod") + #expect(WeaviateFieldID.apiKey == "wvApiKey") + #expect(WeaviateFieldID.skipTLSVerify == "wvSkipTLSVerify") + #expect(WeaviateFieldID.authMethod != "esAuthMethod") + #expect(WeaviateFieldID.apiKey != "esApiKey") + } + + @Test("API key mode without a key is refused") + func apiKeyRequiresValue() { + #expect(throws: WeaviateError.configuration("Enter a Weaviate API key.")) { + _ = try WeaviateConnectionSettings.parse( + host: "localhost", + port: 8_080, + usesTLS: false, + fields: [WeaviateFieldID.authMethod: "apiKey"] + ) + } + } + + @Test("A pasted key is not sent when Auth Method is None") + func noneModeIgnoresAPastedKey() throws { + let settings = try WeaviateConnectionSettings.parse( + host: "localhost", + port: 8_080, + usesTLS: false, + fields: [ + WeaviateFieldID.authMethod: "none", + WeaviateFieldID.apiKey: "wv-secret" + ] + ) + #expect(settings.auth.authorizationHeader == nil) + } + + @Test("None mode does not require a key") + func noneModeConnects() throws { + let settings = try WeaviateConnectionSettings.parse( + host: "localhost", + port: 0, + usesTLS: false, + fields: [WeaviateFieldID.authMethod: "none"] + ) + #expect(settings.port == 8_080) + #expect(settings.auth.authorizationHeader == nil) + #expect(try settings.baseURL().absoluteString == "http://localhost:8080") + } + + @Test("TLS uses https") + func tlsUsesHTTPS() throws { + let settings = try WeaviateConnectionSettings.parse( + host: "example.weaviate.cloud", + port: 443, + usesTLS: true, + fields: [ + WeaviateFieldID.authMethod: "apiKey", + WeaviateFieldID.apiKey: "key" + ] + ) + #expect(try settings.baseURL().absoluteString == "https://example.weaviate.cloud:443") + #expect(settings.auth.authorizationHeader == "Bearer key") + } +} + +@Suite("Weaviate query tags") +struct WeaviateQueryTests { + @Test("Browse tags round-trip") + func browseRoundTrip() throws { + let encoded = WeaviateBrowseQuery.encode( + collection: "Article", + offset: 10, + limit: 25, + sorts: [WeaviateSortSpec(column: "title", ascending: true)], + filters: [WeaviateFilterSpec(column: "title", op: "=", value: "Hello")], + logicMode: "AND", + propertyNames: ["uuid", "title"] + ) + #expect(WeaviateBrowseQuery.isTagged(encoded)) + let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) + #expect(parsed.collection == "Article") + #expect(parsed.offset == 10) + #expect(parsed.limit == 25) + #expect(parsed.usesGraphQL) + #expect(parsed.filters.first?.value == "Hello") + } + + @Test("An unfiltered browse stays on REST objects") + func unfilteredUsesREST() throws { + let encoded = WeaviateBrowseQuery.encode( + collection: "Article", + offset: 0, + limit: 25, + sorts: [], + filters: [], + logicMode: "AND", + propertyNames: ["uuid", "title"] + ) + let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) + #expect(!parsed.usesGraphQL) + } + + @Test("Write tags round-trip") + func writeRoundTrip() throws { + let original = WeaviateWriteRequest( + method: "PATCH", + path: "/v1/objects/abc", + query: ["class": "Article"], + body: "{\"title\":\"x\"}" + ) + let encoded = WeaviateWriteCodec.encode(original) + #expect(WeaviateWriteCodec.isTagged(encoded)) + #expect(WeaviateWriteCodec.decode(encoded) == original) + } +} + +@Suite("Weaviate GraphQL and console") +struct WeaviateGraphQLTests { + @Test("A Get query asks for _additional id and vector") + func getQueryIncludesAdditional() throws { + let query = try WeaviateGraphQL.getQuery( + collection: "Article", + properties: ["uuid", "title", "vector"], + limit: 10, + offset: 0, + sorts: [], + filters: [WeaviateFilterSpec(column: "title", op: "=", value: "Hello")], + logicMode: "AND", + schema: ["title": WeaviateProperty(name: "title", dataType: "text")] + ) + #expect(query.contains("Get")) + #expect(query.contains("Article")) + #expect(query.contains("title")) + #expect(query.contains("_additional { id vector }")) + #expect(query.contains("operator: Equal")) + #expect(query.contains("valueText: \"Hello\"")) + #expect(!query.contains(" uuid ")) + } + + @Test("GraphQL detection") + func detection() { + #expect(WeaviateGraphQL.looksLikeGraphQL("{ Get { Article { title } } }")) + #expect(WeaviateGraphQL.looksLikeGraphQL("query { Get { Article { title } } }")) + #expect(!WeaviateGraphQL.looksLikeGraphQL("GET /v1/schema")) + } + + @Test("Console parser reads a method and path") + func consoleParser() throws { + let request = try #require(WeaviateConsoleParser.parse("GET /v1/schema")) + #expect(request.method == "GET") + #expect(request.path == "/v1/schema") + #expect(request.body == nil) + + let withBody = try #require(WeaviateConsoleParser.parse("POST /v1/graphql\n{ \"query\": \"{ Get { Article { title } } }\" }")) + #expect(withBody.method == "POST") + #expect(withBody.body?.contains("Get") == true) + } + + @Test("A SQL delete is not a console request") + func sqlIsNotConsole() { + #expect(WeaviateConsoleParser.parse("DELETE FROM Article") == nil) + #expect(WeaviateConsoleParser.parse("UPDATE Article SET title = 'x'") == nil) + #expect(WeaviateConsoleParser.parse("GET schema") == nil) + } +} + +@Suite("Weaviate columns") +struct WeaviateSchemaTests { + @Test("uuid leads and vector trails, and both are the primary key surface") + func columnOrder() { + let collection = WeaviateCollection( + name: "Article", + properties: [WeaviateProperty(name: "title", dataType: "text")] + ) + let columns = WeaviateSchema.columns(for: collection) + #expect(columns.map(\.name) == ["uuid", "title", "vector"]) + #expect(columns.first?.isPrimaryKey == true) + #expect(WeaviateSchema.immutableColumns == ["uuid", "vector"]) + } + + @Test("Object and array properties display as JSON") + func displayTextEncodesCollections() { + #expect(WeaviateJSON.displayText(["title": "Hello"]) == "{\"title\":\"Hello\"}") + let vector = WeaviateJSON.displayText([0.1, 0.2]) + #expect(vector?.hasPrefix("[") == true) + #expect(vector?.hasSuffix("]") == true) + #expect(WeaviateJSON.displayText(true) == "true") + #expect(WeaviateJSON.displayText(NSNull()) == nil) + } +} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateClientTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateClientTests.swift new file mode 100644 index 0000000000..2ea2b8300b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateClientTests.swift @@ -0,0 +1,279 @@ +import Foundation +import Testing +@testable import TableProWeaviateCore + +@Suite("Weaviate client") +struct WeaviateClientTests { + @Test("Connect reads ready and meta, and stores the version") + func connectStoresVersion() async throws { + let transport = FakeWeaviateTransport() + transport.respond(method: "GET", path: "/v1/.well-known/ready", status: 200, body: ".") + transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) + let client = testClient(transport: transport) + + try await client.connect() + + #expect(client.serverVersion == "1.27.0") + #expect(transport.requests.map { $0.url.path } == ["/v1/.well-known/ready", "/v1/meta"]) + } + + @Test("An API key is sent as a Bearer header") + func apiKeyIsBearer() async throws { + let transport = FakeWeaviateTransport() + transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) + let client = testClient( + transport: transport, + auth: WeaviateAuth(method: .apiKey, apiKey: "wv-secret") + ) + + try await client.ping() + + #expect(transport.requests.first?.headers["Authorization"] == "Bearer wv-secret") + } + + @Test("A ping asks an endpoint that checks the key") + func pingIsAuthenticated() async throws { + let transport = FakeWeaviateTransport() + transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) + let client = testClient(transport: transport) + + try await client.ping() + + #expect(transport.requests.map { $0.url.path } == ["/v1/meta"]) + } + + @Test("Anonymous auth sends no Authorization header") + func anonymousHasNoAuthorization() async throws { + let transport = FakeWeaviateTransport() + transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) + let client = testClient(transport: transport) + + try await client.ping() + + #expect(transport.requests.first?.headers["Authorization"] == nil) + } + + @Test("Schema lists collections and properties") + func schemaListsCollections() async throws { + let transport = FakeWeaviateTransport() + transport.respond(method: "GET", path: "/v1/schema", status: 200, json: WeaviateFixtures.schema) + let client = testClient(transport: transport) + + let collections = try await client.schema() + + #expect(collections.map(\.name) == ["Article"]) + #expect(collections.first?.properties.map(\.name) == ["title", "wordCount"]) + #expect(collections.first?.properties.map(\.dataType) == ["text", "int"]) + } + + @Test("Objects include uuid, properties and the vector as display text") + func objectsIncludeUUIDAndVector() async throws { + let transport = FakeWeaviateTransport() + transport.respond(method: "GET", path: "/v1/objects", status: 200, json: WeaviateFixtures.objects) + let client = testClient(transport: transport) + + let objects = try await client.objects(collection: "Article", limit: 25, offset: 0) + let object = try #require(objects.first) + let columns = ["uuid", "title", "wordCount", "vector"] + let row = WeaviateObjectCodec.row(for: object, columns: columns) + + #expect(object.uuid == WeaviateFixtures.articleUUID) + #expect(row[0] == WeaviateFixtures.articleUUID) + #expect(row[1] == "Hello") + #expect(row[2] == "12") + #expect(row[3]?.contains("0.1") == true) + #expect(row[3]?.hasPrefix("[") == true) + #expect(transport.requests.first?.url.query?.contains("class=Article") == true) + #expect(transport.requests.first?.url.query?.contains("include=vector") == true) + } + + @Test("HTTP 401 becomes an authentication error") + func unauthorizedIsAuthentication() async throws { + let transport = FakeWeaviateTransport() + transport.respond( + method: "GET", + path: "/v1/meta", + status: 401, + json: ["error": [["message": "invalid api key"]]] + ) + let client = testClient(transport: transport) + + await #expect(throws: WeaviateError.authentication("invalid api key")) { + try await client.ping() + } + } + + @Test("A server error body is surfaced") + func serverErrorMessage() async throws { + let transport = FakeWeaviateTransport() + transport.respond( + method: "GET", + path: "/v1/schema", + status: 500, + json: ["error": [["message": "store unavailable"]]] + ) + let client = testClient(transport: transport) + + await #expect(throws: WeaviateError.api(status: 500, message: "store unavailable")) { + _ = try await client.schema() + } + } + + @Test("GraphQL Get rows flatten uuid from _additional") + func graphqlGetFlattensUUID() async throws { + let transport = FakeWeaviateTransport() + transport.respond(method: "POST", path: "/v1/graphql", status: 200, json: WeaviateFixtures.graphqlGet) + let client = testClient(transport: transport) + + let response = try await client.graphql("{ Get { Article { title } } }") + let objects = WeaviateObjectCodec.objects(fromGraphQL: response.json as Any) + let object = try #require(objects.first) + + #expect(object.uuid == WeaviateFixtures.articleUUID) + #expect(object.properties["title"] == "Hello") + #expect(object.vectorText?.contains("0.1") == true) + } + + @Test("GraphQL errors in a 200 response still fail") + func graphqlErrorsFail() async throws { + let transport = FakeWeaviateTransport() + transport.respond( + method: "POST", + path: "/v1/graphql", + status: 200, + json: ["errors": [["message": "Cannot query field"]]] + ) + let client = testClient(transport: transport) + + await #expect(throws: WeaviateError.api(status: 200, message: "Cannot query field")) { + _ = try await client.graphql("{ Get { Missing { title } } }") + } + } +} + +@Suite("Weaviate uuid edits") +struct WeaviateUUIDEditTests { + @Test("An update is a PATCH keyed by uuid and does not write uuid or vector") + func updateIsPatchByUUID() async throws { + let batch = WeaviateStatementGenerator.generate( + collection: "Article", + columns: ["uuid", "title", "vector"], + typeNames: ["uuid", "text", "vector"], + changes: [ + WeaviateTrackedChange( + kind: .update, + uuid: WeaviateFixtures.articleUUID, + values: [:], + cellChanges: [ + WeaviateCellChange(column: "title", newText: "Edited"), + WeaviateCellChange(column: "vector", newText: "[9,9]"), + WeaviateCellChange(column: "uuid", newText: "nope") + ] + ) + ] + ) + let request = try #require(batch.requests.first) + #expect(request.method == "PATCH") + #expect(request.path == "/v1/objects/\(WeaviateFixtures.articleUUID)") + #expect(request.query["class"] == "Article") + let body = try #require(request.body) + #expect(body.contains("\"title\":\"Edited\"")) + #expect(!body.contains("vector")) + #expect(!body.contains("nope")) + + let transport = FakeWeaviateTransport() + transport.respond( + method: "PATCH", + path: "/v1/objects/\(WeaviateFixtures.articleUUID)", + status: 200, + json: ["id": WeaviateFixtures.articleUUID] + ) + let client = testClient(transport: transport) + let response = try await client.execute(write: request) + #expect(response.statusCode == 200) + #expect(transport.requests.first?.httpMethodMatchesPatch == true) + } + + @Test("A delete is DELETE /v1/objects/{uuid}") + func deleteUsesUUID() async throws { + let batch = WeaviateStatementGenerator.generate( + collection: "Article", + columns: ["uuid", "title"], + typeNames: ["uuid", "text"], + changes: [ + WeaviateTrackedChange( + kind: .delete, + uuid: WeaviateFixtures.articleUUID, + values: [:], + cellChanges: [] + ) + ] + ) + let request = try #require(batch.requests.first) + #expect(request.method == "DELETE") + #expect(request.path.hasSuffix(WeaviateFixtures.articleUUID)) + + let transport = FakeWeaviateTransport() + transport.respond( + method: "DELETE", + path: "/v1/objects/\(WeaviateFixtures.articleUUID)", + status: 204, + body: "" + ) + let client = testClient(transport: transport) + let response = try await client.execute(write: request) + #expect(response.statusCode == 204) + } + + @Test("An update without a uuid is skipped") + func updateWithoutUUIDIsSkipped() { + let batch = WeaviateStatementGenerator.generate( + collection: "Article", + columns: ["uuid", "title"], + typeNames: ["uuid", "text"], + changes: [ + WeaviateTrackedChange( + kind: .update, + uuid: nil, + values: [:], + cellChanges: [WeaviateCellChange(column: "title", newText: "Edited")] + ) + ] + ) + #expect(batch.requests.isEmpty) + #expect(batch.skipped == [WeaviateSkippedChange(kind: .update, reason: .missingUUID)]) + } + + @Test("Insert posts the collection and properties, and an explicit uuid") + func insertPostsObject() throws { + let batch = WeaviateStatementGenerator.generate( + collection: "Article", + columns: ["uuid", "title", "wordCount"], + typeNames: ["uuid", "text", "int"], + changes: [ + WeaviateTrackedChange( + kind: .insert, + uuid: WeaviateFixtures.articleUUID, + values: [ + "uuid": WeaviateFixtures.articleUUID, + "title": "Hello", + "wordCount": "12" + ], + cellChanges: [] + ) + ] + ) + let request = try #require(batch.requests.first) + #expect(request.method == "POST") + #expect(request.path == "/v1/objects") + let body = try #require(request.body) + #expect(body.contains("\"class\":\"Article\"")) + #expect(body.contains("\"id\":\"\(WeaviateFixtures.articleUUID)\"")) + #expect(body.contains("\"title\":\"Hello\"")) + #expect(body.contains("\"wordCount\":12")) + } +} + +private extension WeaviateHTTPRequest { + var httpMethodMatchesPatch: Bool { method == "PATCH" } +} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift new file mode 100644 index 0000000000..4b3336785c --- /dev/null +++ b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift @@ -0,0 +1,565 @@ +import Foundation +import Testing +@testable import TableProWeaviateCore + +private let articleTypes = [ + "title": "text", + "wordCount": "int", + "ratio": "number", + "live": "boolean", + "published": "date", + "tags": "text[]", + "scores": "int[]" +] + +private let articleSchema = articleTypes.mapValues { WeaviateProperty(name: "", dataType: $0) } + +private func operand(_ column: String, _ op: String, _ value: String, second: String? = nil) throws -> String { + try WeaviateFilterBuilder.operand( + for: WeaviateFilterSpec(column: column, op: op, value: value, secondValue: second), + types: articleTypes + ) +} + +@Suite("Weaviate filter value types") +struct WeaviateFilterValueTypeTests { + @Test("Each data type picks the value field Weaviate demands") + func valueFieldFollowsDataType() throws { + #expect(try operand("title", "=", "Hello").contains("valueText: \"Hello\"")) + #expect(try operand("wordCount", "=", "12").contains("valueInt: 12")) + #expect(try operand("ratio", ">", "1.5").contains("valueNumber: 1.5")) + #expect(try operand("live", "=", "true").contains("valueBoolean: true")) + #expect(try operand("published", ">", "2024-01-31T00:00:00Z") + .contains("valueDate: \"2024-01-31T00:00:00Z\"")) + } + + @Test("An array property filters on its element type") + func arrayUsesElementField() throws { + #expect(try operand("tags", "=", "alpha").contains("valueText: \"alpha\"")) + #expect(try operand("scores", "=", "3").contains("valueInt: 3")) + } + + @Test("The uuid column filters as text on the id path") + func uuidFiltersAsText() throws { + let clause = try operand(WeaviateSchema.uuidColumn, "=", "c8f5c3e0-1b2a-4d3e-9f10-111213141516") + #expect(clause.contains("path: [\"id\"]")) + #expect(clause.contains("valueText:")) + } + + @Test("A value that is not a number is refused instead of becoming zero") + func badNumberThrows() { + #expect(throws: WeaviateFilterError.notANumber(column: "wordCount", value: "abc")) { + _ = try operand("wordCount", "=", "abc") + } + #expect(throws: WeaviateFilterError.notANumber(column: "ratio", value: "abc")) { + _ = try operand("ratio", "=", "abc") + } + } + + @Test("A value that is not a boolean is refused instead of becoming false") + func badBooleanThrows() { + #expect(throws: WeaviateFilterError.notABoolean(column: "live", value: "yes")) { + _ = try operand("live", "=", "yes") + } + } + + @Test("A date needs a full RFC 3339 timestamp") + func badDateThrows() { + #expect(throws: WeaviateFilterError.notADate(column: "published", value: "2024-01-31")) { + _ = try operand("published", ">", "2024-01-31") + } + #expect(WeaviateDateLiteral.isRFC3339("2024-01-31T00:00:00+07:00")) + #expect(WeaviateDateLiteral.isRFC3339("2024-01-31T00:00:00.123Z")) + } +} + +@Suite("Weaviate filter operators") +struct WeaviateFilterOperatorTests { + @Test("Substring operators become Like patterns") + func likePatterns() throws { + #expect(try operand("title", "CONTAINS", "ell").contains("operator: Like valueText: \"*ell*\"")) + #expect(try operand("title", "STARTS WITH", "He").contains("valueText: \"He*\"")) + #expect(try operand("title", "ENDS WITH", "lo").contains("valueText: \"*lo\"")) + #expect(try operand("title", "NOT CONTAINS", "ell").hasPrefix("{ operator: Not operands: [")) + } + + @Test("IN and NOT IN become ContainsAny") + func listOperators() throws { + let inClause = try operand("wordCount", "IN", "10, 30") + #expect(inClause.contains("operator: ContainsAny valueInt: [10, 30]")) + let notIn = try operand("title", "NOT IN", "a,b") + #expect(notIn.contains("operator: ContainsNone valueText: [\"a\", \"b\"]")) + } + + @Test("IN with no values is refused") + func emptyListThrows() { + #expect(throws: WeaviateFilterError.emptyList(column: "title")) { + _ = try operand("title", "IN", " , ") + } + } + + @Test("BETWEEN becomes a bounded And and needs its upper bound") + func betweenBounds() throws { + let clause = try operand("wordCount", "BETWEEN", "5", second: "10") + #expect(clause.hasPrefix("{ operator: And operands: [")) + #expect(clause.contains("operator: GreaterThanEqual valueInt: 5")) + #expect(clause.contains("operator: LessThanEqual valueInt: 10")) + + #expect(throws: WeaviateFilterError.missingUpperBound(column: "wordCount")) { + _ = try operand("wordCount", "BETWEEN", "5") + } + } + + @Test("IS NULL maps to IsNull in both directions") + func nullOperators() throws { + #expect(try operand("title", "IS NULL", "").contains("operator: IsNull valueBoolean: true")) + #expect(try operand("title", "IS NOT NULL", "").contains("operator: IsNull valueBoolean: false")) + } + + @Test("An operator Weaviate cannot express is reported, not dropped") + func unsupportedOperatorsThrow() { + #expect(throws: WeaviateFilterError.unsupportedOperator("REGEX")) { + _ = try operand("title", "REGEX", "^a") + } + } + + @Test("Emptiness counts with len(), which is what Weaviate offers") + func emptinessUsesLength() throws { + #expect(try operand("title", "IS EMPTY", "") + .contains("path: [\"len(title)\"] operator: Equal valueInt: 0")) + #expect(try operand("title", "IS NOT EMPTY", "") + .contains("path: [\"len(title)\"] operator: GreaterThan valueInt: 0")) + #expect(throws: WeaviateFilterError.textMatchNeedsText(column: "wordCount", op: "IS EMPTY")) { + _ = try operand("wordCount", "IS EMPTY", "") + } + } + + @Test("Comparing text, and matching a number as text, are both refused") + func mismatchedOperatorsThrow() { + #expect(throws: WeaviateFilterError.comparisonNeedsNumberOrDate(column: "title", op: ">")) { + _ = try operand("title", ">", "Row 1") + } + #expect(throws: WeaviateFilterError.textMatchNeedsText(column: "wordCount", op: "CONTAINS")) { + _ = try operand("wordCount", "CONTAINS", "1") + } + } + + @Test("The vector column cannot be filtered") + func vectorFilterThrows() { + #expect(throws: WeaviateFilterError.vectorNotFilterable(column: "vector")) { + _ = try operand(WeaviateSchema.vectorColumn, "=", "[1,2]") + } + } + + @Test("A quote in a value cannot break out of the GraphQL string") + func valuesAreEscaped() throws { + let clause = try operand("title", "=", "a\"b\\c") + #expect(clause.contains("valueText: \"a\\\"b\\\\c\"")) + } + + @Test("Several filters join under one logic operator") + func logicMode() throws { + let built = try WeaviateFilterBuilder.graphQLWhere( + filters: [ + WeaviateFilterSpec(column: "title", op: "=", value: "a"), + WeaviateFilterSpec(column: "wordCount", op: ">", value: "3") + ], + logicMode: "OR", + types: articleTypes + ) + let clause = try #require(built) + #expect(clause.hasPrefix("{ operator: Or operands: [")) + } +} + +@Suite("Weaviate sorting") +struct WeaviateSortTests { + @Test("A uuid sort reaches GraphQL, which can sort by the object id") + func uuidSortUsesGraphQL() throws { + let encoded = WeaviateBrowseQuery.encode( + collection: "Article", + offset: 0, + limit: 25, + sorts: [WeaviateSortSpec(column: WeaviateSchema.uuidColumn, ascending: false)], + filters: [], + logicMode: "AND", + propertyNames: ["uuid", "title"] + ) + let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) + #expect(parsed.usesGraphQL) + + let query = try WeaviateGraphQL.getQuery( + collection: "Article", + properties: parsed.propertyNames, + limit: parsed.limit, + offset: parsed.offset, + sorts: parsed.sortableSorts, + filters: [], + logicMode: "AND", + schema: articleSchema + ) + #expect(query.contains("sort: [{ path: [\"id\"] order: desc }]")) + } + + @Test("A vector sort is dropped, because Weaviate has no such property") + func vectorSortIsDropped() throws { + let encoded = WeaviateBrowseQuery.encode( + collection: "Article", + offset: 0, + limit: 25, + sorts: [WeaviateSortSpec(column: WeaviateSchema.vectorColumn, ascending: true)], + filters: [], + logicMode: "AND", + propertyNames: ["uuid", "vector"] + ) + let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) + #expect(parsed.sortableSorts.isEmpty) + #expect(!parsed.usesGraphQL) + } + + @Test("BETWEEN survives the browse tag round-trip") + func secondValueRoundTrips() throws { + let encoded = WeaviateBrowseQuery.encode( + collection: "Article", + offset: 0, + limit: 25, + sorts: [], + filters: [WeaviateFilterSpec(column: "wordCount", op: "BETWEEN", value: "5", secondValue: "10")], + logicMode: "AND", + propertyNames: ["uuid"] + ) + let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) + #expect(parsed.filters.first?.secondValue == "10") + } +} + +@Suite("Weaviate request paths") +struct WeaviatePathTests { + private let base = URL(string: "http://localhost:8080")! + + @Test("A console path keeps its query string instead of encoding the question mark") + func consoleQueryStringSurvives() throws { + let url = try #require( + WeaviatePathEncoding.resolve("/v1/objects?class=Article&limit=10", against: base) + ) + #expect(url.path == "/v1/objects") + #expect(!url.absoluteString.contains("%3F")) + #expect(url.query == "class=Article&limit=10") + } + + @Test("A path and passed query items merge") + func mergedQueryItems() throws { + let url = try #require( + WeaviatePathEncoding.resolve("/v1/objects?class=Article", query: ["limit": "5"], against: base) + ) + let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(items.contains(URLQueryItem(name: "class", value: "Article"))) + #expect(items.contains(URLQueryItem(name: "limit", value: "5"))) + } + + @Test("An already-encoded segment is not encoded twice") + func encodedSegmentSurvives() throws { + let uuid = "c8f5c3e0-1b2a-4d3e-9f10-111213141516" + let url = try #require( + WeaviatePathEncoding.resolve("/v1/objects/\(WeaviatePathEncoding.segment(uuid))", against: base) + ) + #expect(url.path == "/v1/objects/\(uuid)") + } + + @Test("An absolute or relative path is refused") + func hostiledPathsRefused() { + #expect(WeaviatePathEncoding.resolve("http://evil.example/v1", against: base) == nil) + #expect(WeaviatePathEncoding.resolve("v1/schema", against: base) == nil) + } +} + +@Suite("Weaviate response decoding") +struct WeaviateResponseDecodingTests { + @Test("A vector component at the edge of Int does not trap") + func hugeVectorComponent() { + let object = WeaviateObject( + uuid: "u1", + className: "Article", + properties: [:], + vector: [9_223_372_036_854_775_808.0, 1.5] + ) + let text = object.vectorText + #expect(text?.hasPrefix("[9.223372036854776e+18") == true) + #expect(text?.hasSuffix("1.5]") == true) + } + + @Test("A Get row keeps the _additional fields a vector search returns") + func additionalFieldsBecomeColumns() { + let objects = WeaviateObjectCodec.objects(fromGraphQL: [ + "data": [ + "Get": [ + "Article": [ + [ + "title": "Hello", + "_additional": ["id": "u1", "distance": 0.42, "score": "0.9"] + ] + ] + ] + ] + ]) + let object = objects.first + #expect(object?.uuid == "u1") + #expect(object?.properties["title"] == "Hello") + #expect(object?.properties["distance"] == "0.42") + #expect(object?.properties["score"] == "0.9") + } + + @Test("A property keeps its name when _additional carries the same one") + func propertyWinsOverAdditional() { + let objects = WeaviateObjectCodec.objects(fromGraphQL: [ + "data": ["Get": ["Article": [["distance": "mine", "_additional": ["id": "u1", "distance": 0.42]]]]] + ]) + #expect(objects.first?.properties["distance"] == "mine") + #expect(objects.first?.properties["_additional.distance"] == "0.42") + } + + @Test("A response body is parsed once and still compares by its bytes") + func responseEquality() { + let body = Data(#"{"a":1}"#.utf8) + let first = WeaviateHTTPResponse(statusCode: 200, body: body) + let second = WeaviateHTTPResponse(statusCode: 200, body: body) + #expect(first == second) + #expect(WeaviateJSON.dictionary(first.json)?["a"] as? Int == 1) + #expect(WeaviateJSON.dictionary(first.json)?["a"] as? Int == 1) + #expect(first != WeaviateHTTPResponse(statusCode: 500, body: body)) + } +} + +@Suite("Weaviate write generation") +struct WeaviateWriteGenerationTests { + @Test("A duplicate column name does not trap the generator") + func duplicateColumnNames() throws { + let batch = WeaviateStatementGenerator.generate( + collection: "Article", + columns: ["uuid", "title", "title"], + typeNames: ["uuid", "text", "int"], + changes: [ + WeaviateTrackedChange( + kind: .update, + uuid: "c8f5c3e0-1b2a-4d3e-9f10-111213141516", + values: [:], + cellChanges: [WeaviateCellChange(column: "title", newText: "Edited")] + ) + ] + ) + let body = try #require(batch.requests.first?.body) + #expect(body.contains("\"title\":\"Edited\"")) + } + + @Test("A delete with no uuid is reported rather than dropped in silence") + func deleteWithoutUUIDIsReported() { + let batch = WeaviateStatementGenerator.generate( + collection: "Article", + columns: ["uuid", "title"], + typeNames: ["uuid", "text"], + changes: [WeaviateTrackedChange(kind: .delete, uuid: nil, values: [:], cellChanges: [])] + ) + #expect(batch.requests.isEmpty) + #expect(batch.skipped == [WeaviateSkippedChange(kind: .delete, reason: .missingUUID)]) + } + + @Test("An update touching only read-only columns is reported") + func updateWithNoEditableColumns() { + let batch = WeaviateStatementGenerator.generate( + collection: "Article", + columns: ["uuid", "vector"], + typeNames: ["uuid", "vector"], + changes: [ + WeaviateTrackedChange( + kind: .update, + uuid: "c8f5c3e0-1b2a-4d3e-9f10-111213141516", + values: [:], + cellChanges: [WeaviateCellChange(column: "vector", newText: "[1,2]")] + ) + ] + ) + #expect(batch.requests.isEmpty) + #expect(batch.skipped == [WeaviateSkippedChange(kind: .update, reason: .noEditableColumns)]) + } +} + + +@Suite("Weaviate selection sets") +struct WeaviateSelectionSetTests { + private func query(_ properties: [WeaviateProperty]) throws -> String { + try WeaviateGraphQL.getQuery( + collection: "Article", + properties: properties.map(\.name), + limit: 5, + offset: 0, + sorts: [], + filters: [], + logicMode: "AND", + schema: Dictionary(properties.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + ) + } + + @Test("A structured property asks for its own fields instead of failing the query") + func structuredPropertiesGetSubSelections() throws { + let built = try query([ + WeaviateProperty(name: "title", dataType: "text"), + WeaviateProperty(name: "place", dataType: "geoCoordinates"), + WeaviateProperty(name: "phone", dataType: "phoneNumber") + ]) + #expect(built.contains("place { latitude longitude }")) + #expect(built.contains("phone { input internationalFormatted")) + #expect(built.contains(" title ")) + } + + @Test("An object property selects its declared nested properties") + func objectSelectsNested() throws { + let built = try query([ + WeaviateProperty( + name: "meta", + dataTypes: ["object"], + nestedProperties: [ + WeaviateProperty(name: "k", dataType: "text"), + WeaviateProperty(name: "inner", dataTypes: ["object"], nestedProperties: [ + WeaviateProperty(name: "deep", dataType: "int") + ]) + ] + ) + ]) + #expect(built.contains("meta { k inner { deep } }")) + } + + @Test("An object with no declared nested properties is left out") + func emptyObjectIsOmitted() throws { + let built = try query([ + WeaviateProperty(name: "title", dataType: "text"), + WeaviateProperty(name: "meta", dataTypes: ["object"], nestedProperties: []) + ]) + #expect(!built.contains("meta")) + #expect(built.contains("title")) + } + + @Test("A cross-reference asks for the referenced ids") + func crossReferenceUsesFragments() throws { + let built = try query([ + WeaviateProperty(name: "category", dataTypes: ["Category", "Topic"], nestedProperties: []) + ]) + #expect(built.contains("category { ... on Category { _additional { id } } ... on Topic { _additional { id } } }")) + } + + @Test("A nested property list survives schema parsing") + func schemaParsesNestedProperties() throws { + let collections = WeaviateSchema.collections(from: [ + "classes": [[ + "class": "Article", + "properties": [ + ["name": "meta", "dataType": ["object"], "nestedProperties": [["name": "k", "dataType": ["text"]]]], + ["name": "category", "dataType": ["Category"]] + ] + ]] + ]) + let properties = try #require(collections.first?.properties) + #expect(properties.first?.nestedProperties.map(\.name) == ["k"]) + #expect(WeaviatePropertyShape.of(properties[1]) == .crossReference(["Category"])) + } +} + +@Suite("Weaviate value round-trip") +struct WeaviateParsedValueTests { + @Test("Text keeps its own punctuation instead of being parsed as JSON") + func textStaysText() { + #expect(WeaviateJSON.parsedValue("{\"a\":1}", typeName: "text") as? String == "{\"a\":1}") + #expect(WeaviateJSON.parsedValue("[1,2]", typeName: "text") as? String == "[1,2]") + #expect(WeaviateJSON.parsedValue("{\"a\":1}", typeName: "string") as? String == "{\"a\":1}") + #expect(WeaviateJSON.parsedValue("2024-01-31T00:00:00Z", typeName: "date") as? String == "2024-01-31T00:00:00Z") + } + + @Test("A type the grid renders as JSON parses back") + func structuredParsesBack() { + #expect(WeaviateJSON.parsedValue("[\"a\",\"b\"]", typeName: "text[]") as? [String] == ["a", "b"]) + #expect(WeaviateJSON.parsedValue("[1,2]", typeName: "int[]") as? [Int] == [1, 2]) + let object = WeaviateJSON.parsedValue("{\"k\":\"v\"}", typeName: "object") as? [String: Any] + #expect(object?["k"] as? String == "v") + let geo = WeaviateJSON.parsedValue("{\"latitude\":1}", typeName: "geoCoordinates") as? [String: Any] + #expect(geo?["latitude"] as? Int == 1) + } + + @Test("A scalar parses to its own type, and an unparsable one stays text") + func scalarsParse() { + #expect(WeaviateJSON.parsedValue("12", typeName: "int") as? Int == 12) + #expect(WeaviateJSON.parsedValue("1.5", typeName: "number") as? Double == 1.5) + #expect(WeaviateJSON.parsedValue("true", typeName: "boolean") as? Bool == true) + #expect(WeaviateJSON.parsedValue("abc", typeName: "int") as? String == "abc") + #expect(WeaviateJSON.parsedValue("yes", typeName: "boolean") as? String == "yes") + } + + @Test("A property named with a leading underscore is written, and a synthetic column is not") + func underscoreNamedPropertyIsWritten() throws { + let batch = WeaviateStatementGenerator.generate( + collection: "Event", + columns: ["uuid", "_source", "distance"], + typeNames: ["uuid", "text", "number"], + changes: [ + WeaviateTrackedChange( + kind: .update, + uuid: "c8f5c3e0-1b2a-4d3e-9f10-111213141516", + values: [:], + cellChanges: [ + WeaviateCellChange(column: "_source", newText: "manual"), + WeaviateCellChange(column: "_additional.distance", newText: "0.4") + ] + ) + ] + ) + let body = try #require(batch.requests.first?.body) + #expect(body.contains("\"_source\":\"manual\"")) + #expect(!body.contains("_additional")) + } +} + +@Suite("Weaviate console requests") +struct WeaviateConsoleRequestTests { + @Test("A body typed on the request line is kept") + func inlineBodySurvives() throws { + let request = try #require( + WeaviateConsoleParser.parse("POST /v1/objects {\"class\": \"Article\"}") + ) + #expect(request.method == "POST") + #expect(request.path == "/v1/objects") + #expect(request.body == "{\"class\": \"Article\"}") + } + + @Test("A body on the lines below still wins") + func multilineBodyWins() throws { + let request = try #require(WeaviateConsoleParser.parse("POST /v1/graphql\n{ \"query\": \"x\" }")) + #expect(request.body == "{ \"query\": \"x\" }") + } + + @Test("Any path is resolved under /v1") + func everyPathIsPrefixed() throws { + #expect(try #require(WeaviateConsoleParser.parse("GET /nodes")).path == "/v1/nodes") + #expect(try #require(WeaviateConsoleParser.parse("POST /batch/objects")).path == "/v1/batch/objects") + #expect(try #require(WeaviateConsoleParser.parse("GET /v1/schema")).path == "/v1/schema") + #expect(try #require(WeaviateConsoleParser.parse("GET /")).path == "/") + } + + @Test("SQL is still not a console request") + func sqlIsRefused() { + #expect(WeaviateConsoleParser.parse("DELETE FROM Article") == nil) + #expect(WeaviateConsoleParser.parse("UPDATE Article SET title = 'x'") == nil) + } + + @Test("A browse that is not showing the vector does not ask for it") + func vectorIsOptional() throws { + let withVector = try WeaviateGraphQL.getQuery( + collection: "Article", properties: ["uuid", "title"], limit: 5, offset: 0, + sorts: [], filters: [], logicMode: "AND", schema: [:], includeVector: true + ) + let withoutVector = try WeaviateGraphQL.getQuery( + collection: "Article", properties: ["uuid", "title"], limit: 5, offset: 0, + sorts: [], filters: [], logicMode: "AND", schema: [:], includeVector: false + ) + #expect(withVector.contains("_additional { id vector }")) + #expect(withoutVector.contains("_additional { id }")) + #expect(!withoutVector.contains("vector")) + } +} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateTestSupport.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateTestSupport.swift new file mode 100644 index 0000000000..cd6a8ba115 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateTestSupport.swift @@ -0,0 +1,120 @@ +import Foundation +@testable import TableProWeaviateCore + +final class FakeWeaviateTransport: WeaviateTransport, @unchecked Sendable { + struct Route: Equatable { + let method: String + let path: String + } + + var responses: [String: WeaviateHTTPResponse] = [:] + var requests: [WeaviateHTTPRequest] = [] + var error: WeaviateError? + + func send(_ request: WeaviateHTTPRequest) async throws -> WeaviateHTTPResponse { + if let error { + throw error + } + requests.append(request) + let key = Self.key(method: request.method, url: request.url) + if let response = responses[key] ?? responses[request.url.path] { + return response + } + throw WeaviateError.malformedResponse("No fake response for \(key)") + } + + func cancelAll() {} + + func respond(method: String, path: String, status: Int, json: Any) { + let data = (try? JSONSerialization.data(withJSONObject: json)) ?? Data() + responses[Self.key(method: method, path: path)] = WeaviateHTTPResponse(statusCode: status, body: data) + } + + func respond(method: String, path: String, status: Int, body: String) { + responses[Self.key(method: method, path: path)] = WeaviateHTTPResponse( + statusCode: status, + body: Data(body.utf8) + ) + } + + static func key(method: String, path: String) -> String { + "\(method.uppercased()) \(path)" + } + + static func key(method: String, url: URL) -> String { + key(method: method, path: url.path) + } +} + +func testSettings(auth: WeaviateAuth = WeaviateAuth(method: .none)) -> WeaviateConnectionSettings { + WeaviateConnectionSettings( + host: "localhost", + port: 8_080, + usesTLS: false, + auth: auth, + skipTLSVerify: false + ) +} + +func testClient( + transport: FakeWeaviateTransport, + auth: WeaviateAuth = WeaviateAuth(method: .none) +) -> WeaviateClient { + WeaviateClient(settings: testSettings(auth: auth), transport: transport, timeout: { 30 }) +} + +enum WeaviateFixtures { + static let articleUUID = "c8f5c3e0-1b2a-4d3e-9f10-111213141516" + + static var schema: [String: Any] { + [ + "classes": [ + [ + "class": "Article", + "vectorizer": "none", + "properties": [ + ["name": "title", "dataType": ["text"]], + ["name": "wordCount", "dataType": ["int"]] + ] + ] + ] + ] + } + + static var objects: [String: Any] { + [ + "objects": [ + [ + "id": articleUUID, + "class": "Article", + "properties": ["title": "Hello", "wordCount": 12], + "vector": [0.1, 0.2, 0.3] + ] + ], + "totalResults": 1 + ] + } + + static var graphqlGet: [String: Any] { + [ + "data": [ + "Get": [ + "Article": [ + [ + "title": "Hello", + "wordCount": 12, + "_additional": [ + "id": articleUUID, + "vector": [0.1, 0.2] + ] + ] + ] + ] + ] + ] + } + + static var meta: [String: Any] { + ["version": "1.27.0"] + } +} diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist index 3a80c6e3ae..ae8234631a 100644 --- a/Plugins/BeancountDriverPlugin/Info.plist +++ b/Plugins/BeancountDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds Beancount diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index ef6bb318fc..1ff5e9424b 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 66c614e6ed..0a65f60446 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds csv diff --git a/Plugins/CSVImportPlugin/Info.plist b/Plugins/CSVImportPlugin/Info.plist index 4e75827ae5..18dba8028a 100644 --- a/Plugins/CSVImportPlugin/Info.plist +++ b/Plugins/CSVImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesImportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index e19d005d46..c0ce92deb8 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/ClickHouseDriverPlugin/ClickHouseHTTPChunks.swift b/Plugins/ClickHouseDriverPlugin/ClickHouseHTTPChunks.swift new file mode 100644 index 0000000000..db1d779973 --- /dev/null +++ b/Plugins/ClickHouseDriverPlugin/ClickHouseHTTPChunks.swift @@ -0,0 +1,116 @@ +import Foundation +import TableProPluginKit + +/// The response body of a streaming read, as the bytes the socket produced. +/// +/// `URLSession.AsyncBytes.lines` hands back a UTF-8 decoded `String`, which replaces every byte a +/// `String` column holds that is not valid UTF-8 before anything can look at it, and iterating the +/// same sequence a byte at a time costs an async suspension per byte: measured on a million-row +/// read, 7.6s for `lines` and 44s for the bytes under it. A per-task delegate hands over the `Data` +/// untouched and about 85 KB at a time, and the same read then costs 0.18s. It is a *task* delegate +/// rather than a session one so the driver keeps its own session, and with it the +/// `ClickHouseTLSDelegate` that answers the server trust challenge. +/// +/// One chunk is in flight at a time: the transfer is suspended as each chunk is handed over and +/// resumed once the reader has finished with it, so a slow writer downstream stops the download +/// rather than letting the rest of the result pile up in memory. That resume is why the body is +/// read through `forEachChunk` and not by iterating a sequence: a caller cannot forget it. +internal final class ClickHouseHTTPChunks: NSObject, URLSessionDataDelegate, @unchecked Sendable { + /// An error body is text the user reads, not a result, so it is held whole up to this and the + /// rest dropped. ClickHouse exception text runs to a few kilobytes at most. + private static let errorBodyByteCap = 65_536 + + private let stream: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private let lock = NSLock() + private var task: URLSessionDataTask? + private var isTransferSuspended = false + private var failureStatusCode: Int? + private var failureBody = Data() + + internal init(session: URLSession, request: URLRequest) { + (stream, continuation) = AsyncThrowingStream.makeStream() + super.init() + + let dataTask = session.dataTask(with: request) + dataTask.delegate = self + lock.withLock { task = dataTask } + continuation.onTermination = { _ in dataTask.cancel() } + dataTask.resume() + } + + /// The transfer is torn down on every way out, a thrown error and a cancellation included. A + /// reader that walks away leaves the task suspended mid-body with nothing left to resume it, so + /// without this the connection stays open until the driver disconnects. + internal func forEachChunk(_ body: (Data) async throws -> Void) async throws { + defer { endTransfer() } + for try await chunk in stream { + try await body(chunk) + resumeTransfer() + } + } + + internal func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode >= 400 { + lock.withLock { failureStatusCode = statusCode } + } + completionHandler(.allow) + } + + internal func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + let isFailure = lock.withLock { () -> Bool in + guard failureStatusCode != nil else { return false } + let room = Self.errorBodyByteCap - failureBody.count + if room > 0 { failureBody.append(data.prefix(room)) } + return true + } + guard !isFailure else { return } + + continuation.yield(data) + let shouldSuspend = lock.withLock { () -> Bool in + guard !isTransferSuspended else { return false } + isTransferSuspended = true + return true + } + guard shouldSuspend else { return } + dataTask.suspend() + } + + internal func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let failure = lock.withLock { () -> Error? in + guard failureStatusCode != nil else { return error } + let body = String(decoding: failureBody, as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + return ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines)) + } + guard let failure else { + continuation.finish() + return + } + continuation.finish(throwing: failure) + } + + private func resumeTransfer() { + let suspended = lock.withLock { () -> URLSessionDataTask? in + guard isTransferSuspended else { return nil } + isTransferSuspended = false + return task + } + suspended?.resume() + } + + private func endTransfer() { + let outstanding = lock.withLock { () -> URLSessionDataTask? in + let current = task + task = nil + isTransferSuspended = false + return current + } + outstanding?.cancel() + continuation.finish() + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift b/Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift new file mode 100644 index 0000000000..489a94ed98 --- /dev/null +++ b/Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift @@ -0,0 +1,69 @@ +// +// ClickHouseParameterBinding.swift +// ClickHouseDriverPlugin +// + +import Foundation +import TableProPluginKit + +nonisolated internal enum ClickHouseParameterBinding { + struct Bound { + let query: String + let params: [String: String?] + } + + /// A ClickHouse HTTP parameter is text: `{p1:String}` takes the characters of `param_p1` + /// verbatim, so bytes handed over as `0xDEADBEEF` compare as those ten characters and every + /// predicate on a binary column matches nothing. Bytes are written into the statement as + /// `unhex('…')` instead, which the server turns back into the value the read produced. The hex + /// is generated here and holds nothing but `0-9A-F`, so it closes no quote. + static func bind(query: String, parameters: [PluginCellValue]) -> Bound { + var converted = "" + converted.reserveCapacity((query as NSString).length) + var paramMap: [String: String?] = [:] + var consumed = 0 + var namedCount = 0 + var inSingleQuote = false + var inDoubleQuote = false + var isEscaped = false + + for char in query { + if isEscaped { + isEscaped = false + converted.append(char) + continue + } + if char == "\\" && (inSingleQuote || inDoubleQuote) { + isEscaped = true + converted.append(char) + continue + } + if char == "'" && !inDoubleQuote { + inSingleQuote.toggle() + } else if char == "\"" && !inSingleQuote { + inDoubleQuote.toggle() + } + guard char == "?", !inSingleQuote, !inDoubleQuote, consumed < parameters.count else { + converted.append(char) + continue + } + let parameter = parameters[consumed] + consumed += 1 + if case .bytes(let data) = parameter { + converted.append(hexLiteral(data)) + continue + } + namedCount += 1 + let name = "p\(namedCount)" + converted.append("{\(name):String}") + paramMap[name] = parameter.asText + } + + return Bound(query: converted, params: paramMap) + } + + static func hexLiteral(_ data: Data) -> String { + let hex = data.map { String(format: "%02X", $0) }.joined() + return "unhex('\(hex)')" + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index fba95f35b0..5a7b02ec87 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -286,8 +286,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let startTime = Date() let queryId = UUID().uuidString - let (convertedQuery, paramMap) = Self.buildClickHouseParams(query: query, parameters: parameters) - let result = try await executeRawWithParams(convertedQuery, params: paramMap, queryId: queryId) + let bound = ClickHouseParameterBinding.bind(query: query, parameters: parameters) + let result = try await executeRawWithParams(bound.query, params: bound.params, queryId: queryId) let executionTime = Date().timeIntervalSince(startTime) return PluginQueryResult( @@ -495,8 +495,9 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Streaming - /// A bounded read is one HTTP request. The unbounded `streamRows` path still pays a separate - /// `LIMIT 0` probe to learn its columns, so it is deliberately not reused here. + /// A bounded read is one HTTP request, and so is the unbounded `streamRows` path: the format + /// both ask for names its columns, so neither pays a `LIMIT 0` probe to learn them. That probe + /// appended to the statement, which a query already carrying a `LIMIT` rejects outright. func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult? { let started = Date() let stream = PluginRowStream.make { continuation, abort in @@ -526,87 +527,70 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return (session, _currentDatabase) } - var trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - while trimmedQuery.hasSuffix(";") { - trimmedQuery = String(trimmedQuery.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) - } - - let request = try buildStreamRequest(query: trimmedQuery, database: database, rowCap: rowCap) - let (bytes, response) = try await session.bytes(for: request) - - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 { - var body = "" - for try await line in bytes.lines { - body += line - } - throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines)) - } + let request = try buildStreamRequest( + query: Self.withoutTrailingSemicolons(query), + database: database, + rowCap: rowCap + ) + try await streamTabSeparatedRows( + request: request, + session: session, + batchSize: min(5_000, rowCap + 1), + continuation: continuation + ) + } - /// JSONCompactEachRowWithNamesAndTypes puts the names on line one and the types on line - /// two, both as positional arrays, so the columns arrive without a second round trip and - /// survive a zero-row result. - var columns: [String] = [] - var columnTypeNames: [String] = [] + /// `TabSeparatedWithNamesAndTypes` carries the names on line one and the types on line two, so + /// the columns arrive with the rows and survive a result holding none. It is the format the + /// non-streaming read asks for too, which is what keeps an exported value and the same value in + /// the grid the same text, and unlike a JSON string it can carry a byte no encoding covers. + private func streamTabSeparatedRows( + request: URLRequest, + session: URLSession, + batchSize: Int, + continuation: AsyncThrowingStream.Continuation + ) async throws { + var decoder = ClickHouseTabSeparatedRowDecoder() var headerSent = false - let batchSize = min(5_000, rowCap + 1) var batch: [PluginRow] = [] batch.reserveCapacity(batchSize) - for try await line in bytes.lines { - try Task.checkCancellation() - - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedLine.isEmpty { continue } - guard let lineData = trimmedLine.data(using: .utf8) else { continue } - - if columns.isEmpty { - columns = (try? JSONSerialization.jsonObject(with: lineData) as? [String]) ?? [] - continue - } - if columnTypeNames.isEmpty { - columnTypeNames = (try? JSONSerialization.jsonObject(with: lineData) as? [String]) ?? [] - continuation.yield(.header(PluginStreamHeader( - columns: columns, - columnTypeNames: columnTypeNames, - estimatedRowCount: nil - ))) - headerSent = true - continue - } - - guard let values = try? JSONSerialization.jsonObject(with: lineData) as? [Any] else { continue } - var row: [PluginCellValue] = [] - row.reserveCapacity(columns.count) - for index in columns.indices { - let value: Any? = index < values.count ? values[index] : nil - row.append(Self.boundedCellValue(value)) - } - batch.append(row) - if batch.count >= batchSize { - continuation.yield(.rows(batch)) - batch.removeAll(keepingCapacity: true) - } - } - - if !headerSent { + func sendHeader(_ header: ClickHouseTabSeparatedRowDecoder.Header) { + guard !headerSent else { return } + headerSent = true continuation.yield(.header(PluginStreamHeader( - columns: columns, - columnTypeNames: columnTypeNames, + columns: header.columns, + columnTypeNames: header.columnTypeNames, estimatedRowCount: nil ))) } + + try await ClickHouseHTTPChunks(session: session, request: request).forEachChunk { chunk in + try Task.checkCancellation() + let rows = decoder.consume(chunk) + if let header = decoder.header { + sendHeader(header) + } + batch.append(contentsOf: rows) + guard batch.count >= batchSize else { return } + continuation.yield(.rows(batch)) + batch.removeAll(keepingCapacity: true) + } + + batch.append(contentsOf: decoder.finish()) + sendHeader(decoder.header ?? ClickHouseTabSeparatedRowDecoder.Header(columns: [], columnTypeNames: [])) if !batch.isEmpty { continuation.yield(.rows(batch)) } continuation.finish() } - private static func boundedCellValue(_ value: Any?) -> PluginCellValue { - guard let value, !(value is NSNull) else { return .null } - if let str = value as? String { return .text(str) } - if let num = value as? NSNumber { return .text(NumberText.text(for: num)) } - if let jsonStr = NumberText.json(from: value, sortedKeys: false) { return .text(jsonStr) } - return .text(String(describing: value)) + private static func withoutTrailingSemicolons(_ query: String) -> String { + var trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + while trimmed.hasSuffix(";") { + trimmed = String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed } func streamRows(query: String) -> AsyncThrowingStream { @@ -633,87 +617,13 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return (session, _currentDatabase) } - var trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - while trimmedQuery.hasSuffix(";") { - trimmedQuery = String(trimmedQuery.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) - } - - let headerResult = try await executeRaw("\(trimmedQuery) LIMIT 0") - continuation.yield(.header(PluginStreamHeader( - columns: headerResult.columns, - columnTypeNames: headerResult.columnTypeNames, - estimatedRowCount: nil - ))) - - let columnOrder = headerResult.columns - - guard !columnOrder.isEmpty else { - continuation.finish() - return - } - - let streamRequest = try buildStreamRequest( - query: trimmedQuery, database: database + let request = try buildStreamRequest(query: Self.withoutTrailingSemicolons(query), database: database) + try await streamTabSeparatedRows( + request: request, + session: session, + batchSize: 5_000, + continuation: continuation ) - - let (bytes, response) = try await session.bytes(for: streamRequest) - - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 { - var body = "" - for try await line in bytes.lines { - body += line - } - throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - let batchSize = 5_000 - var batch: [PluginRow] = [] - batch.reserveCapacity(batchSize) - - for try await line in bytes.lines { - try Task.checkCancellation() - - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedLine.isEmpty { continue } - - guard let lineData = trimmedLine.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { - continue - } - - var row: [PluginCellValue] = [] - for colName in columnOrder { - if let value = json[colName] { - if value is NSNull { - row.append(.null) - } else if let str = value as? String { - row.append(.text(str)) - } else if let num = value as? NSNumber { - row.append(.text(NumberText.text(for: num))) - } else { - if let jsonStr = NumberText.json(from: value, sortedKeys: false) { - row.append(.text(jsonStr)) - } else { - row.append(.text(String(describing: value))) - } - } - } else { - row.append(.null) - } - } - - batch.append(row) - if batch.count >= batchSize { - continuation.yield(.rows(batch)) - batch.removeAll(keepingCapacity: true) - } - } - - if !batch.isEmpty { - continuation.yield(.rows(batch)) - } - - continuation.finish() } private func buildStreamRequest(query: String, database: String, rowCap: Int? = nil) throws -> URLRequest { @@ -729,15 +639,16 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { if !database.isEmpty { queryItems.append(URLQueryItem(name: "database", value: database)) } + queryItems.append(URLQueryItem( + name: "default_format", + value: ClickHouseResponseClassifier.requestedFormat + )) if let rowCap { /// The bound rides as an HTTP setting so the SQL in the body stays exactly what the /// user wrote. One row past the cap, so a full page can be told from a truncated one. - queryItems.append(URLQueryItem(name: "default_format", value: "JSONCompactEachRowWithNamesAndTypes")) queryItems.append(URLQueryItem(name: "max_result_rows", value: String(rowCap + 1))) queryItems.append(URLQueryItem(name: "result_overflow_mode", value: "break")) queryItems.append(URLQueryItem(name: "cancel_http_readonly_queries_on_client_close", value: "1")) - } else { - queryItems.append(URLQueryItem(name: "default_format", value: "JSONEachRow")) } components.queryItems = queryItems diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift index c14d0f002b..5d2cbd513c 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift @@ -47,7 +47,7 @@ extension ClickHousePluginDriver { let httpResponse = response as? HTTPURLResponse if let httpResponse, httpResponse.statusCode >= 400 { - let body = String(data: data, encoding: .utf8) ?? "Unknown error" + let body = String(decoding: data, as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion let exceptionCode = httpResponse.value(forHTTPHeaderField: "X-ClickHouse-Exception-Code") ?? "none" Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode) exception \(exceptionCode): \(body)") throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines)) @@ -154,54 +154,4 @@ extension ClickHousePluginDriver { return request } - - /// Convert `?` placeholders to `{p1:String}` and build parameter map for ClickHouse HTTP params. - static func buildClickHouseParams( - query: String, - parameters: [PluginCellValue] - ) -> (String, [String: String?]) { - var converted = "" - var paramIndex = 0 - var inSingleQuote = false - var inDoubleQuote = false - var isEscaped = false - - for char in query { - if isEscaped { - isEscaped = false - converted.append(char) - continue - } - if char == "\\" && (inSingleQuote || inDoubleQuote) { - isEscaped = true - converted.append(char) - continue - } - if char == "'" && !inDoubleQuote { - inSingleQuote.toggle() - } else if char == "\"" && !inSingleQuote { - inDoubleQuote.toggle() - } - if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count { - paramIndex += 1 - converted.append("{p\(paramIndex):String}") - } else { - converted.append(char) - } - } - - var paramMap: [String: String?] = [:] - for i in 0.. TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index ef6bb318fc..1ff5e9424b 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist index 5d91dcfa09..8c38b7dea5 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist +++ b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds Cloudflare R2 SQL diff --git a/Plugins/DamengDriverPlugin/DamengPluginDriver+Session.swift b/Plugins/DamengDriverPlugin/DamengPluginDriver+Session.swift index 8f12a3c6f2..4f5e7a4ad8 100644 --- a/Plugins/DamengDriverPlugin/DamengPluginDriver+Session.swift +++ b/Plugins/DamengDriverPlugin/DamengPluginDriver+Session.swift @@ -22,9 +22,8 @@ extension DamengPluginDriver { /// Runs a statement, and when the connection died under it, rebuilds the connection and /// runs it once more. /// - /// This is the shape MySQL (`MySQLPluginDriver.executeWithReconnect`) and PostgreSQL - /// (`LibPQDriverCore.executeWithReconnect`) already ship, and it is what the Dameng - /// driver's own documentation has always claimed to do. A stop is never retried: the user + /// This is the shape MySQL (`MySQLPluginDriver.executeWithReconnect`) already ships, and it + /// is what the Dameng driver's own documentation has always claimed to do. A stop is never retried: the user /// asked for the statement to end. func executeWithReconnect( query: String, diff --git a/Plugins/DamengDriverPlugin/Info.plist b/Plugins/DamengDriverPlugin/Info.plist index 721e8b6803..da99fbc7aa 100644 --- a/Plugins/DamengDriverPlugin/Info.plist +++ b/Plugins/DamengDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds Dameng diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index 0b0de66a10..da17a5a58e 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index ef6bb318fc..1ff5e9424b 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/ElasticsearchDriverPlugin/Info.plist b/Plugins/ElasticsearchDriverPlugin/Info.plist index 54f69d38bc..b5fdecd437 100644 --- a/Plugins/ElasticsearchDriverPlugin/Info.plist +++ b/Plugins/ElasticsearchDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.53.0 TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index ef6bb318fc..1ff5e9424b 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/HTMLExportPlugin/Info.plist b/Plugins/HTMLExportPlugin/Info.plist index 7d3d412288..c621f63e4c 100644 --- a/Plugins/HTMLExportPlugin/Info.plist +++ b/Plugins/HTMLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds html diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index c2bb35e22c..8215860cbb 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds json diff --git a/Plugins/JSONImportPlugin/Info.plist b/Plugins/JSONImportPlugin/Info.plist index 26b752a7a8..83099a26e5 100644 --- a/Plugins/JSONImportPlugin/Info.plist +++ b/Plugins/JSONImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesImportFormatIds json diff --git a/Plugins/KafkaDriverPlugin/Info.plist b/Plugins/KafkaDriverPlugin/Info.plist index bbc26bccee..e77aa7ce69 100644 --- a/Plugins/KafkaDriverPlugin/Info.plist +++ b/Plugins/KafkaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds Kafka diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index ef6bb318fc..1ff5e9424b 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index 6cccc9fe96..73f154e0a9 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index 0b0de66a10..da17a5a58e 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift index 6296a13439..b40fc13c61 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift @@ -6,17 +6,18 @@ // import Foundation +import TableProMSSQLCore public enum MSSQLObjectQueries { public static func escapeLiteral(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "''") + MSSQLStringLiteral.escaped(value) } /// Reads sys.sql_modules, never INFORMATION_SCHEMA.ROUTINES.ROUTINE_DEFINITION. That column is /// nvarchar(4000) and silently returns the first 4000 characters of a longer body, which looks /// like a routine that ends mid-statement. public static func routineList(schema: String) -> String { - let schemaLiteral = escapeLiteral(schema) + let schemaLiteral = MSSQLStringLiteral.quoted(schema) return """ SELECT o.name, @@ -41,7 +42,7 @@ public enum MSSQLObjectQueries { FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id LEFT JOIN sys.sql_modules m ON m.object_id = o.object_id - WHERE s.name = '\(schemaLiteral)' + WHERE s.name = \(schemaLiteral) AND o.type IN ('P', 'FN', 'IF', 'TF') AND o.is_ms_shipped = 0 ORDER BY o.type, o.name @@ -54,7 +55,7 @@ public enum MSSQLObjectQueries { FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id JOIN sys.schemas s ON s.schema_id = o.schema_id - WHERE s.name = '\(escapeLiteral(schema))' AND o.name = '\(escapeLiteral(name))' + WHERE s.name = \(MSSQLStringLiteral.quoted(schema)) AND o.name = \(MSSQLStringLiteral.quoted(name)) """ } @@ -62,8 +63,8 @@ public enum MSSQLObjectQueries { /// one table is one more predicate on the same query, so the per-table list and the /// schema-wide list cannot disagree. public static func triggerList(schema: String, table: String?) -> String { - let schemaLiteral = escapeLiteral(schema) - let tablePredicate = table.map { "AND parent.name = '\(escapeLiteral($0))'" } ?? "" + let schemaLiteral = MSSQLStringLiteral.quoted(schema) + let tablePredicate = table.map { "AND parent.name = \(MSSQLStringLiteral.quoted($0))" } ?? "" return """ SELECT t.name, @@ -78,7 +79,7 @@ public enum MSSQLObjectQueries { JOIN sys.schemas s ON s.schema_id = parent.schema_id JOIN sys.trigger_events te ON te.object_id = t.object_id WHERE t.parent_class = 1 - AND s.name = '\(schemaLiteral)' + AND s.name = \(schemaLiteral) \(tablePredicate) ORDER BY parent.name, t.name, te.type_desc """ diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 04249db1ac..d665270492 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -273,7 +273,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { if MSSQLCapabilities.parse(serverVersion).hasCreateOrAlterView { return "CREATE OR ALTER VIEW \(quoted) AS\nSELECT * FROM table_name;" } - return "IF OBJECT_ID('\(viewName)', 'V') IS NOT NULL DROP VIEW \(quoted);\nCREATE VIEW \(quoted) AS\nSELECT * FROM table_name;" + let viewLiteral = MSSQLStringLiteral.quoted(viewName) + return "IF OBJECT_ID(\(viewLiteral), 'V') IS NOT NULL DROP VIEW \(quoted);\nCREATE VIEW \(quoted) AS\nSELECT * FROM table_name;" } func castColumnToText(_ column: String) -> String { @@ -285,10 +286,6 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { self._currentSchema = config.additionalFields["mssqlSchema"].flatMap { $0.isEmpty ? nil : $0 } ?? "dbo" } - private var escapedSchema: String { - _currentSchema.replacingOccurrences(of: "'", with: "''") - } - // MARK: - Connection func connect() async throws { @@ -673,26 +670,26 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await execute(query: query) } - let (convertedQuery, paramDecls, paramAssigns) = Self.buildSpExecuteSql( - query: query, parameters: parameters.map { $0.asText } + let statement = MSSQLParameterBatch.spExecuteSql( + query: query, parameters: parameters.map(Self.parameter) ) - guard !paramDecls.isEmpty else { + guard !statement.isEmpty else { return try await execute(query: query) } - let sql = "EXEC sp_executesql N'\(Self.escapeNString(convertedQuery))', N'\(paramDecls)', \(paramAssigns)" + let sql = "EXEC sp_executesql N'\(Self.escapeNString(statement.query))', " + + "N'\(statement.declarations)', \(statement.assignments)" return try await execute(query: sql) } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { - let esc = effectiveSchemaEscaped(schema) - let escapedTable = table.replacingOccurrences(of: "'", with: "''") - let objectName = "[\(esc)].[\(escapedTable)]" + let objectLiteral = MSSQLStringLiteral.quoted( + MSSQLSchemaQueries.bracketed(schema: effectiveSchema(schema), table: table)) let sql = """ SELECT SUM(p.rows) FROM sys.partitions p - WHERE p.object_id = OBJECT_ID(N'\(objectName)') AND p.index_id IN (0, 1) + WHERE p.object_id = OBJECT_ID(\(objectLiteral)) AND p.index_id IN (0, 1) """ let result = try await execute(query: sql) if let row = result.rows.first, let cell = row.first, let str = cell.asText { @@ -774,16 +771,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { offset: Int, columnKinds: [String: PluginColumnKind] ) -> String? { - let whereClause = PluginSQLFilter.buildWhereClause( - filters: filters, - logicMode: logicMode, - columnKinds: columnKinds, - quoteIdentifier: mssqlQuoteIdentifier, - escapeTypedValue: mssqlEscapeValue, - regexCondition: { quoted, value in - "\(quoted) LIKE '%\(value.replacingOccurrences(of: "'", with: "''"))%'" - } - ) + let whereClause = mssqlWhereClause(filters: filters, logicMode: logicMode, columnKinds: columnKinds) let orderBy = PluginSQLFilter.buildOrderByClause( sortColumns: sortColumns, columns: columns, quoteIdentifier: mssqlQuoteIdentifier ) ?? "ORDER BY (SELECT NULL)" @@ -799,13 +787,43 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { quoteIdentifier(identifier) } + /// The shared builder writes the `LIKE` arms' literal itself rather than asking for one, so + /// SQL Server answers those arms first and lets every other operator fall through to it. + private func mssqlWhereClause( + filters: [(column: String, op: String, value: String)], + logicMode: String, + columnKinds: [String: PluginColumnKind] + ) -> String { + let conditions = filters.compactMap { filter -> String? in + let quoted = mssqlQuoteIdentifier(filter.column) + if let like = MSSQLStringLiteral.likeCondition( + quotedColumn: quoted, op: filter.op, value: filter.value + ) { + return like + } + return PluginSQLFilter.buildFilterCondition( + column: filter.column, + op: filter.op, + value: filter.value, + kind: columnKinds[filter.column], + quoteIdentifier: mssqlQuoteIdentifier, + escapeTypedValue: mssqlEscapeValue, + regexCondition: { quoted, value in + "\(quoted) LIKE \(MSSQLStringLiteral.quoted("%\(value)%"))" + } + ) + } + guard !conditions.isEmpty else { return "" } + return conditions.joined(separator: logicMode == "and" ? " AND " : " OR ") + } + private func mssqlEscapeValue(_ value: String, kind: PluginColumnKind?) -> String { PluginSQLLiteral.escapedLiteral( value, kind: kind, trueLiteral: "1", falseLiteral: "0", - quote: { "'\($0.replacingOccurrences(of: "'", with: "''"))'" } + quote: MSSQLStringLiteral.quoted ) } @@ -814,61 +832,17 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// Convert `?` placeholders to `@p1, @p2, ...` and build sp_executesql components. /// Returns: (convertedQuery, paramDeclarations, paramAssignments) - private static func buildSpExecuteSql( - query: String, - parameters: [String?] - ) -> (String, String, String) { - var converted = "" - var paramIndex = 0 - var inSingleQuote = false - var inDoubleQuote = false - let chars = Array(query) - let length = chars.count - - var i = 0 - while i < length { - let char = chars[i] - - // Handle doubled quotes (T-SQL escape: '' inside strings, "" inside identifiers) - if char == "'" && inSingleQuote && i + 1 < length && chars[i + 1] == "'" { - converted.append("''") - i += 2 - continue - } - if char == "\"" && inDoubleQuote && i + 1 < length && chars[i + 1] == "\"" { - converted.append("\"\"") - i += 2 - continue - } - - if char == "'" && !inDoubleQuote { - inSingleQuote.toggle() - } else if char == "\"" && !inSingleQuote { - inDoubleQuote.toggle() - } - - if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count { - paramIndex += 1 - converted.append("@p\(paramIndex)") - } else { - converted.append(char) - } - i += 1 - } - - let count = paramIndex - guard count > 0 else { - return (converted, "", "") + /// A binary cell has no text, and asking it for some is how every one of them reached the + /// server as `NULL`. + private static func parameter(_ value: PluginCellValue) -> MSSQLParameter { + switch value { + case .null: + return .null + case .text(let text): + return .text(text) + case .bytes(let data): + return .bytes(data) } - let decls = (1...count).map { "@p\($0) NVARCHAR(MAX)" }.joined(separator: ", ") - let assigns = (1...count).map { i -> String in - if let value = parameters[i - 1] { - return "@p\(i) = N'\(escapeNString(value))'" - } - return "@p\(i) = NULL" - }.joined(separator: ", ") - - return (converted, decls, assigns) } /// Escape single quotes for N'...' string literals in SQL Server. @@ -881,8 +855,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return schema } - func effectiveSchemaEscaped(_ schema: String?) -> String { - MSSQLSchemaQueries.escape(effectiveSchema(schema)) + func effectiveSchemaQuoted(_ schema: String?) -> String { + MSSQLStringLiteral.quoted(effectiveSchema(schema)) } } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+DDL.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+DDL.swift index 704d8840b1..3a8f847c56 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+DDL.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+DDL.swift @@ -116,22 +116,25 @@ extension MSSQLPluginDriver { // Rename column first so subsequent statements reference the correct name if oldColumn.name != newColumn.name { - let escapedPath = "\(escapeStringLiteral(_currentSchema)).\(escapeStringLiteral(table)).\(escapeStringLiteral(oldColumn.name))" - stmts.append("EXEC sp_rename '\(escapedPath)', '\(escapeStringLiteral(newColumn.name))', 'COLUMN'") + let path = MSSQLStringLiteral.quoted("\(_currentSchema).\(table).\(oldColumn.name)") + let renamed = MSSQLStringLiteral.quoted(newColumn.name) + stmts.append("EXEC sp_rename \(path), \(renamed), 'COLUMN'") } let colName = quoteIdentifier(newColumn.name) // Drop existing default constraint before ALTER COLUMN or default change if (defaultChanged || needsTypeChange) && oldColumn.defaultValue != nil { - let objectId = escapeStringLiteral("\(_currentSchema).\(table)") + let objectLiteral = MSSQLStringLiteral.quoted(qt) + let dropPrefix = MSSQLStringLiteral.quoted("ALTER TABLE \(qt) DROP CONSTRAINT ") stmts.append(""" DECLARE @dfName NVARCHAR(256); \ SELECT @dfName = dc.name FROM sys.default_constraints dc \ JOIN sys.columns c ON dc.parent_column_id = c.column_id AND dc.parent_object_id = c.object_id \ - WHERE c.name = '\(escapeStringLiteral(newColumn.name))' \ - AND dc.parent_object_id = OBJECT_ID('\(objectId)'); \ - IF @dfName IS NOT NULL EXEC('ALTER TABLE \(qt) DROP CONSTRAINT [' + @dfName + ']') + WHERE c.name = \(MSSQLStringLiteral.quoted(newColumn.name)) \ + AND dc.parent_object_id = OBJECT_ID(\(objectLiteral)); \ + IF @dfName IS NOT NULL BEGIN DECLARE @dropSql NVARCHAR(MAX) = \(dropPrefix) + QUOTENAME(@dfName); \ + EXEC(@dropSql); END """) } @@ -185,17 +188,14 @@ extension MSSQLPluginDriver { /// `sys.check_constraints.parent_column_id` is 0 for a multi-column check, so the columns come /// from `sys.sql_expression_dependencies`, which lists them for both shapes. func fetchCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] { - // Bracket-quoting makes `target` a safe identifier, but it lands inside a string literal - // here, so it needs literal escaping too: a legal name like O'Reilly would otherwise - // terminate the literal. - let target = escapeStringLiteral(mssqlQualifiedTable(table)) + let targetLiteral = MSSQLStringLiteral.quoted(mssqlQualifiedTable(table)) let query = """ SELECT cc.name, cc.definition, cc.is_not_trusted, COL_NAME(d.referenced_id, d.referenced_minor_id) FROM sys.check_constraints cc LEFT JOIN sys.sql_expression_dependencies d ON d.referencing_id = cc.object_id AND d.referenced_minor_id > 0 - WHERE cc.parent_object_id = OBJECT_ID(\'\(target)\') + WHERE cc.parent_object_id = OBJECT_ID(\(targetLiteral)) ORDER BY cc.name """ let result = try await execute(query: query) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index 1d3cee5d57..141dd5b162 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -13,11 +13,11 @@ extension MSSQLPluginDriver { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let resolved = effectiveSchema(schema) - let esc = MSSQLSchemaQueries.escape(resolved) + let schemaLiteral = MSSQLStringLiteral.quoted(resolved) let sql = """ SELECT t.TABLE_NAME, t.TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES t - WHERE t.TABLE_SCHEMA = '\(esc)' + WHERE t.TABLE_SCHEMA = \(schemaLiteral) AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW') ORDER BY t.TABLE_NAME """ @@ -31,8 +31,8 @@ extension MSSQLPluginDriver { } func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { - let escapedTable = table.replacingOccurrences(of: "'", with: "''") - let esc = effectiveSchemaEscaped(schema) + let tableLiteral = MSSQLStringLiteral.quoted(table) + let schemaLiteral = effectiveSchemaQuoted(schema) let sql = """ SELECT c.COLUMN_NAME, @@ -53,11 +53,11 @@ extension MSSQLPluginDriver { ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - AND tc.TABLE_SCHEMA = '\(esc)' - AND tc.TABLE_NAME = '\(escapedTable)' + AND tc.TABLE_SCHEMA = \(schemaLiteral) + AND tc.TABLE_NAME = \(tableLiteral) ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME - WHERE c.TABLE_NAME = '\(escapedTable)' - AND c.TABLE_SCHEMA = '\(esc)' + WHERE c.TABLE_NAME = \(tableLiteral) + AND c.TABLE_SCHEMA = \(schemaLiteral) ORDER BY c.ORDINAL_POSITION """ let result = try await execute(query: sql) @@ -153,7 +153,7 @@ extension MSSQLPluginDriver { func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { /// Bracket-escaped for the identifier and literal-escaped for the string it sits in: /// SQL Server allows both `]` and `'` in an identifier. - let bracketedFull = MSSQLSchemaQueries.escape( + let objectLiteral = MSSQLStringLiteral.quoted( MSSQLSchemaQueries.bracketed(schema: effectiveSchema(schema), table: table)) let sql = """ SELECT i.name, i.is_unique, i.is_primary_key, c.name AS column_name, i.type_desc @@ -162,7 +162,7 @@ extension MSSQLPluginDriver { ON i.object_id = ic.object_id AND i.index_id = ic.index_id JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id - WHERE i.object_id = OBJECT_ID('\(bracketedFull)') + WHERE i.object_id = OBJECT_ID(\(objectLiteral)) AND i.name IS NOT NULL ORDER BY i.index_id, ic.key_ordinal """ @@ -208,7 +208,7 @@ extension MSSQLPluginDriver { /// The bracketed name is spliced into a string literal, so a name carrying a quote needs /// the literal escape as well as the bracket one. SQL Server allows both characters in an /// identifier. - let objectRef = MSSQLSchemaQueries.escape( + let objectLiteral = MSSQLStringLiteral.quoted( MSSQLSchemaQueries.bracketed(schema: effectiveSchema(schema), table: table)) let sql = """ SELECT i.name, i.type_desc, i.is_unique, i.filter_definition, @@ -218,7 +218,7 @@ extension MSSQLPluginDriver { ON i.object_id = ic.object_id AND i.index_id = ic.index_id JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id - WHERE i.object_id = OBJECT_ID('\(objectRef)') + WHERE i.object_id = OBJECT_ID(\(objectLiteral)) AND i.name IS NOT NULL AND i.is_primary_key = 0 AND i.type IN (1, 2) @@ -270,7 +270,8 @@ extension MSSQLPluginDriver { func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { let esc = MSSQLSchemaQueries.escapeBracket(effectiveSchema(schema)) let bracketedName = name.replacingOccurrences(of: "]", with: "]]") - let sql = "SELECT OBJECT_DEFINITION(OBJECT_ID('[\(esc)].[\(bracketedName)]'))" + let objectLiteral = MSSQLStringLiteral.quoted("[\(esc)].[\(bracketedName)]") + let sql = "SELECT OBJECT_DEFINITION(OBJECT_ID(\(objectLiteral)))" let result = try await execute(query: sql) guard let definition = result.rows.first?[safe: 0]?.asText, !definition.isEmpty else { return nil } guard let range = definition.range(of: "CREATE TRIGGER", options: .caseInsensitive) else { @@ -285,7 +286,7 @@ extension MSSQLPluginDriver { } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { - let esc = effectiveSchemaEscaped(schema) + let schemaLiteral = effectiveSchemaQuoted(schema) let sql = """ SELECT c.TABLE_NAME, @@ -307,9 +308,9 @@ extension MSSQLPluginDriver { ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - AND tc.TABLE_SCHEMA = '\(esc)' + AND tc.TABLE_SCHEMA = \(schemaLiteral) ) pk ON c.TABLE_NAME = pk.TABLE_NAME AND c.COLUMN_NAME = pk.COLUMN_NAME - WHERE c.TABLE_SCHEMA = '\(esc)' + WHERE c.TABLE_SCHEMA = \(schemaLiteral) ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION """ let result = try await execute(query: sql) @@ -377,7 +378,7 @@ extension MSSQLPluginDriver { var tableDDLIncludesForeignKeys: Bool { true } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { - let esc = effectiveSchemaEscaped(schema) + let schemaLiteral = effectiveSchemaQuoted(schema) let sql = """ SELECT tp.name AS table_name, @@ -396,7 +397,7 @@ extension MSSQLPluginDriver { JOIN sys.schemas sr ON tr.schema_id = sr.schema_id JOIN sys.columns cr ON fkc.referenced_object_id = cr.object_id AND fkc.referenced_column_id = cr.column_id - WHERE s.name = '\(esc)' + WHERE s.name = \(schemaLiteral) ORDER BY tp.name, fk.name """ let result = try await execute(query: sql) @@ -472,13 +473,12 @@ extension MSSQLPluginDriver { } func fetchTableDDL(table: String, schema: String?) async throws -> String { - let escapedTable = table.replacingOccurrences(of: "'", with: "''") - let esc = effectiveSchemaEscaped(schema) + let qualified = MSSQLSchemaQueries.bracketed(schema: effectiveSchema(schema), table: table) let cols = try await fetchColumns(table: table, schema: schema) let indexes = try await fetchIndexes(table: table, schema: schema) let fks = try await fetchForeignKeys(table: table, schema: schema) - var ddl = "CREATE TABLE [\(esc)].[\(escapedTable)] (\n" + var ddl = "CREATE TABLE \(qualified) (\n" let colDefs = cols.map { col -> String in var def = " [\(col.name)] \(col.dataType.uppercased())" if col.extra == "IDENTITY" { def += " IDENTITY(1,1)" } @@ -506,16 +506,15 @@ extension MSSQLPluginDriver { } func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let esc = effectiveSchemaEscaped(schema) - let escapedView = "\(esc).\(view.replacingOccurrences(of: "'", with: "''"))" - let sql = "SELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID('\(escapedView)')" + let viewLiteral = MSSQLStringLiteral.quoted("\(effectiveSchema(schema)).\(view)") + let sql = "SELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID(\(viewLiteral))" let result = try await execute(query: sql) return result.rows.first?.first?.asText ?? "" } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - let escapedTable = table.replacingOccurrences(of: "'", with: "''") - let esc = effectiveSchemaEscaped(schema) + let tableLiteral = MSSQLStringLiteral.quoted(table) + let schemaLiteral = effectiveSchemaQuoted(schema) let sql = """ SELECT SUM(p.rows) AS row_count, @@ -528,7 +527,7 @@ extension MSSQLPluginDriver { JOIN sys.allocation_units a ON p.partition_id = a.container_id LEFT JOIN sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' - WHERE t.name = '\(escapedTable)' AND s.name = '\(esc)' + WHERE t.name = \(tableLiteral) AND s.name = \(schemaLiteral) GROUP BY ep.value """ let result = try await execute(query: sql) diff --git a/Plugins/MarkdownExportPlugin/Info.plist b/Plugins/MarkdownExportPlugin/Info.plist index 409a2f52ed..10135b9189 100644 --- a/Plugins/MarkdownExportPlugin/Info.plist +++ b/Plugins/MarkdownExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds md diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index 0b0de66a10..da17a5a58e 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/MySQLDriverPlugin/DatabendCatalog.swift b/Plugins/MySQLDriverPlugin/DatabendCatalog.swift index ae0d45615d..46bb96d821 100644 --- a/Plugins/MySQLDriverPlugin/DatabendCatalog.swift +++ b/Plugins/MySQLDriverPlugin/DatabendCatalog.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -internal enum DatabendCatalog { +nonisolated internal enum DatabendCatalog { static func quoteIdentifier(_ name: String) -> String { guard name.contains("`") else { return "`\(name)`" } let escaped = name diff --git a/Plugins/MySQLDriverPlugin/DatabendResultShape.swift b/Plugins/MySQLDriverPlugin/DatabendResultShape.swift index d67489e730..aab081616a 100644 --- a/Plugins/MySQLDriverPlugin/DatabendResultShape.swift +++ b/Plugins/MySQLDriverPlugin/DatabendResultShape.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -internal enum DatabendResultShape { +nonisolated internal enum DatabendResultShape { static let booleanTypeName = "BOOLEAN" private static let shortFieldType: UInt32 = 2 diff --git a/Plugins/MySQLDriverPlugin/GeometryWKBParser.swift b/Plugins/MySQLDriverPlugin/GeometryWKBParser.swift index 060b1f1051..ecc82573c6 100644 --- a/Plugins/MySQLDriverPlugin/GeometryWKBParser.swift +++ b/Plugins/MySQLDriverPlugin/GeometryWKBParser.swift @@ -8,7 +8,7 @@ import Foundation -enum GeometryWKBParser { +nonisolated enum GeometryWKBParser { /// Parses MySQL's internal geometry binary format to WKT string. /// /// MySQL internal binary format: diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 7f89d51299..8bcf5a589f 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,13 +3,14 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds MySQL MariaDB TiDB Databend + OceanBase diff --git a/Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift b/Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift new file mode 100644 index 0000000000..2b5d3fcbaf --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift @@ -0,0 +1,117 @@ +import CMariaDB +import Foundation +import OSLog + +nonisolated internal enum MariaDBCharacterSet { + private static let logger = Logger(subsystem: "com.TablePro", category: "MariaDBCharacterSet") + + static func establishSession(on mysql: UnsafeMutablePointer, encoding: MySQLConnectionEncoding) -> Bool { + if mysql_set_character_set(mysql, MySQLConnectionEncoding.sessionCharacterSetName) != 0 { + let refusal = errorSummary(of: mysql, encoding: encoding) + if run(MySQLConnectionEncoding.sessionFallbackStatement, on: mysql) { + logger.notice("Server refused utf8mb4 (\(refusal, privacy: .public)), so the session uses utf8") + } else { + logger.warning("Server refused a UTF-8 session (\(refusal, privacy: .public)); keeping its own") + } + } + for statement in encoding.sessionStatements where !run(statement, on: mysql) { + return false + } + return true + } + + static func name(forCollation collation: UInt32) -> String? { + guard let info = mariadb_get_charset_by_nr(collation), let name = info.pointee.csname else { return nil } + return String(cString: name) + } + + static func describeColumns( + of fields: UnsafeMutablePointer?, + count: Int, + encoding: MySQLConnectionEncoding, + flavor: MySQLServerFlavor = .mysql + ) -> MySQLResultColumns { + var columns = MySQLResultColumns() + guard let fields else { return columns } + for index in 0.., + decoding: MySQLColumnDecoding + ) -> String { + guard decoding != .databendBoolean else { return DatabendResultShape.booleanTypeName } + return mysqlTypeToString(field) + } + + private static func typeCode(of field: MYSQL_FIELD, flags: UInt) -> UInt32 { + if (flags & mysqlSetFlag) != 0 { return 248 } + if (flags & mysqlEnumFlag) != 0 { return 247 } + return field.type.rawValue + } + + static func decodedName(of field: MYSQL_FIELD, encoding: MySQLConnectionEncoding) -> String? { + guard let name = field.name else { return nil } + let bytes = UnsafeRawBufferPointer(start: name, count: strnlen(name, Int(field.name_length))) + return mysqlSessionText(bytes, encoding: encoding) + } + + private static func run(_ statement: String, on mysql: UnsafeMutablePointer) -> Bool { + let status = statement.withCString { mysql_real_query(mysql, $0, UInt(strlen($0))) } + if let discarded = mysql_store_result(mysql) { + mysql_free_result(discarded) + } + return status == 0 + } + + private static func errorSummary(of mysql: UnsafeMutablePointer, encoding: MySQLConnectionEncoding) -> String { + let code = mysql_errno(mysql) + guard let message = mysql_error(mysql) else { return "error \(code)" } + return "\(code) \(mysqlSessionText(cString: message, encoding: encoding))" + } +} + +nonisolated func mysqlTypeToString(_ fieldPtr: UnsafePointer) -> String { + let field = fieldPtr.pointee + let flags = UInt(field.flags) + + // MariaDB extended metadata: detect JSON stored as LONGTEXT. + // `MARIADB_CONST_STRING` is length-prefixed (not null-terminated), so we must read + // exactly `attr.length` bytes. `String(cString:)` would scan past the buffer into + // adjacent memory and intermittently fail the comparison when that memory is non-zero. + var attr = MARIADB_CONST_STRING() + if mariadb_field_attr(&attr, fieldPtr, MARIADB_FIELD_ATTR_FORMAT_NAME) == 0, + let str = attr.str, attr.length > 0, + let value = String(data: Data(bytes: str, count: Int(attr.length)), encoding: .utf8), + value == "json" { + return "JSON" + } + + if (flags & mysqlEnumFlag) != 0 { return "ENUM" } + if (flags & mysqlSetFlag) != 0 { return "SET" } + + return mariaDBTypeName( + typeRaw: field.type.rawValue, + flags: flags, + charsetnr: field.charsetnr, + length: field.length + ) +} diff --git a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift index f49a05151e..c6fc918677 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift @@ -5,7 +5,7 @@ import Foundation -internal enum MariaDBFieldClassifier { +nonisolated internal enum MariaDBFieldClassifier { private static let bitType: UInt32 = 16 private static let binaryCharset: UInt32 = 63 private static let blobOrStringTypes: Set = [249, 250, 251, 252, 253, 254] diff --git a/Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift b/Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift new file mode 100644 index 0000000000..e918564ec7 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift @@ -0,0 +1,74 @@ +// +// MariaDBFieldMetadata.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +nonisolated internal let mysqlNotNullFlag: UInt = 0x0001 +nonisolated internal let mysqlPriKeyFlag: UInt = 0x0002 +nonisolated internal let mysqlBinaryFlag: UInt = 0x0080 +nonisolated internal let mysqlEnumFlag: UInt = 0x0100 +nonisolated internal let mysqlAutoIncrementFlag: UInt = 0x0200 +nonisolated internal let mysqlSetFlag: UInt = 0x0800 +nonisolated internal let mysqlBinaryCharset: UInt32 = 63 + +nonisolated internal func makeColumnMeta(name: String, typeName: String, flags: UInt) -> PluginColumnInfo { + PluginColumnInfo( + name: name, + dataType: typeName, + isNullable: (flags & mysqlNotNullFlag) == 0, + isPrimaryKey: (flags & mysqlPriKeyFlag) != 0, + identityKind: (flags & mysqlAutoIncrementFlag) != 0 ? .byDefault : nil + ) +} + +nonisolated internal func mariaDBTypeName( + typeRaw: UInt32, + flags: UInt, + charsetnr: UInt32, + length: UInt +) -> String { + let isBinary = (flags & mysqlBinaryFlag) != 0 && charsetnr == mysqlBinaryCharset + + switch typeRaw { + case 0: return "DECIMAL" + case 1: return "TINYINT" + case 2: return "SMALLINT" + case 3: return "INT" + case 4: return "FLOAT" + case 5: return "DOUBLE" + case 6: return "NULL" + case 7: return "TIMESTAMP" + case 8: return "BIGINT" + case 9: return "MEDIUMINT" + case 10: return "DATE" + case 11: return "TIME" + case 12: return "DATETIME" + case 13: return "YEAR" + case 14: return "NEWDATE" + case 15: return "VARCHAR" + case 16: return "BIT" + case 245: return "JSON" + case 246: return "NEWDECIMAL" + case 247: return "ENUM" + case 248: return "SET" + case 249: + return isBinary ? "TINYBLOB" : "TINYTEXT" + case 250: + return isBinary ? "MEDIUMBLOB" : "MEDIUMTEXT" + case 251: + return isBinary ? "LONGBLOB" : "LONGTEXT" + case 252: + if isBinary { + return length > 65_535 ? "LONGBLOB" : "BLOB" + } else { + return length > 65_535 ? "LONGTEXT" : "TEXT" + } + case 253: return isBinary ? "VARBINARY" : "VARCHAR" + case 254: return isBinary ? "BINARY" : "CHAR" + case 255: return "GEOMETRY" + default: return "UNKNOWN" + } +} diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 819ef2572e..c9e703e640 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -11,27 +11,8 @@ import Foundation import OSLog import TableProPluginKit -// MySQL/MariaDB field flag and charset constants -internal let mysqlNotNullFlag: UInt = 0x0001 -internal let mysqlPriKeyFlag: UInt = 0x0002 -internal let mysqlBinaryFlag: UInt = 0x0080 -internal let mysqlEnumFlag: UInt = 0x0100 -internal let mysqlAutoIncrementFlag: UInt = 0x0200 -internal let mysqlSetFlag: UInt = 0x0800 -internal let mysqlBinaryCharset: UInt32 = 63 - private let logger = Logger(subsystem: "com.TablePro", category: "MariaDBPluginConnection") -internal func makeColumnMeta(name: String, typeName: String, flags: UInt) -> PluginColumnInfo { - PluginColumnInfo( - name: name, - dataType: typeName, - isNullable: (flags & mysqlNotNullFlag) == 0, - isPrimaryKey: (flags & mysqlPriKeyFlag) != 0, - identityKind: (flags & mysqlAutoIncrementFlag) != 0 ? .byDefault : nil - ) -} - // MARK: - Error Types struct MariaDBPluginError: Error { @@ -64,92 +45,6 @@ struct MariaDBPluginQueryResult { var firstRowTime: TimeInterval? } -// MARK: - SSL Configuration - -// MARK: - Type Mapping - -func mysqlTypeToString(_ fieldPtr: UnsafePointer) -> String { - let field = fieldPtr.pointee - let flags = UInt(field.flags) - let length = field.length - - // MariaDB extended metadata: detect JSON stored as LONGTEXT. - // `MARIADB_CONST_STRING` is length-prefixed (not null-terminated), so we must read - // exactly `attr.length` bytes. `String(cString:)` would scan past the buffer into - // adjacent memory and intermittently fail the comparison when that memory is non-zero. - var attr = MARIADB_CONST_STRING() - if mariadb_field_attr(&attr, fieldPtr, MARIADB_FIELD_ATTR_FORMAT_NAME) == 0, - let str = attr.str, attr.length > 0, - let value = String(data: Data(bytes: str, count: Int(attr.length)), encoding: .utf8), - value == "json" { - return "JSON" - } - - if (flags & mysqlEnumFlag) != 0 { return "ENUM" } - if (flags & mysqlSetFlag) != 0 { return "SET" } - - return mariaDBTypeName( - typeRaw: field.type.rawValue, - flags: flags, - charsetnr: field.charsetnr, - length: field.length - ) -} - -/// Pure mapping from raw MySQL/MariaDB field type code + flags to TablePro's -/// column-type-name string. Separated from `mysqlTypeToString` so it can be -/// unit-tested without an actual `MYSQL_FIELD` struct. -internal func mariaDBTypeName( - typeRaw: UInt32, - flags: UInt, - charsetnr: UInt32, - length: UInt -) -> String { - // Binary flag alone is insufficient — MariaDB sets it on text columns with - // binary collation (e.g. utf8mb4_bin for JSON). Only charset 63 is truly binary. - let isBinary = (flags & mysqlBinaryFlag) != 0 && charsetnr == mysqlBinaryCharset - - switch typeRaw { - case 0: return "DECIMAL" - case 1: return "TINYINT" - case 2: return "SMALLINT" - case 3: return "INT" - case 4: return "FLOAT" - case 5: return "DOUBLE" - case 6: return "NULL" - case 7: return "TIMESTAMP" - case 8: return "BIGINT" - case 9: return "MEDIUMINT" - case 10: return "DATE" - case 11: return "TIME" - case 12: return "DATETIME" - case 13: return "YEAR" - case 14: return "NEWDATE" - case 15: return "VARCHAR" - case 16: return "BIT" - case 245: return "JSON" - case 246: return "NEWDECIMAL" - case 247: return "ENUM" - case 248: return "SET" - case 249: - return isBinary ? "TINYBLOB" : "TINYTEXT" - case 250: - return isBinary ? "MEDIUMBLOB" : "MEDIUMTEXT" - case 251: - return isBinary ? "LONGBLOB" : "LONGTEXT" - case 252: - if isBinary { - return length > 65_535 ? "LONGBLOB" : "BLOB" - } else { - return length > 65_535 ? "LONGTEXT" : "TEXT" - } - case 253: return isBinary ? "VARBINARY" : "VARCHAR" - case 254: return isBinary ? "BINARY" : "CHAR" - case 255: return "GEOMETRY" - default: return "UNKNOWN" - } -} - // MARK: - Connection Class final class MariaDBPluginConnection: @unchecked Sendable { @@ -168,6 +63,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { private let sslConfig: SSLConfiguration private let enableCleartextPlugin: Bool private let queryTimeoutSeconds: Int + private let connectionEncoding: MySQLConnectionEncoding private let stateLock = NSLock() private let cancellationGate = PluginQueryCancellationGate() @@ -219,6 +115,29 @@ final class MariaDBPluginConnection: @unchecked Sendable { return _isConnected } + /// Whether the server says the session is inside a transaction, from the status flags in the + /// reply to the last statement. This is the one thing about the session the server does + /// answer for free, and it is exact where reading the statement text is a guess: measured on + /// MySQL 8.4.11, it reports the transaction that `SET autocommit = 0` plus a plain `SELECT` + /// opens, the one inside `/*!40101 BEGIN */`, and the one an `XA START` opens, none of which + /// the text can show. + var isInTransaction: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return _isInTransaction + } + + private var _isInTransaction = false + + private func recordTransactionState(on mysql: UnsafeMutablePointer) { + var serverStatus: UInt32 = 0 + guard mariadb_get_info(mysql, MARIADB_CONNECTION_SERVER_STATUS, &serverStatus) == 0 else { return } + let isOpen = (serverStatus & UInt32(SERVER_STATUS_IN_TRANS)) != 0 + stateLock.lock() + _isInTransaction = isOpen + stateLock.unlock() + } + private var isShuttingDown: Bool { get { stateLock.lock() @@ -240,7 +159,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { database: String, sslConfig: SSLConfiguration, enableCleartextPlugin: Bool = false, - queryTimeoutSeconds: Int = 0 + queryTimeoutSeconds: Int = 0, + connectionEncoding: MySQLConnectionEncoding = .utf8 ) { self.host = host self.port = UInt32(port) @@ -250,6 +170,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { self.sslConfig = sslConfig self.enableCleartextPlugin = enableCleartextPlugin self.queryTimeoutSeconds = queryTimeoutSeconds + self.connectionEncoding = connectionEncoding } deinit { @@ -388,7 +309,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { } } - if result == nil { + guard result != nil, MariaDBCharacterSet.establishSession(on: mysql, encoding: connectionEncoding) else { let error = readError(from: mysql) mysql_close(mysql) throw error @@ -397,18 +318,11 @@ final class MariaDBPluginConnection: @unchecked Sendable { } private func readError(from mysql: UnsafeMutablePointer) -> MariaDBPluginError { - let code = mysql_errno(mysql) - let message: String - if let msgPtr = mysql_error(mysql) { - message = String(cString: msgPtr) - } else { - message = "Unknown error" - } - var sqlState: String? - if let statePtr = mysql_sqlstate(mysql), statePtr[0] != 0 { - sqlState = String(cString: statePtr) - } - return MariaDBPluginError(code: code, message: message, sqlState: sqlState) + MariaDBPluginError( + code: mysql_errno(mysql), + message: mysql_error(mysql).map(decodedMessage) ?? "Unknown error", + sqlState: sqlState(mysql_sqlstate(mysql)) + ) } func disconnect() { @@ -633,6 +547,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { guard !isShuttingDown, let mysql = self.mysql else { throw MariaDBPluginError.notConnected } + defer { recordTransactionState(on: mysql) } let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } @@ -670,45 +585,13 @@ final class MariaDBPluginConnection: @unchecked Sendable { } } - let numFields = Int(mysql_num_fields(resultPtr)) - var columns: [String] = [] - var columnTypes: [UInt32] = [] - var columnTypeNames: [String] = [] - var columnIsBinary: [Bool] = [] - var columnIsBoolean: [Bool] = [] - var columnMeta: [PluginColumnInfo] = [] - columns.reserveCapacity(numFields) - columnTypes.reserveCapacity(numFields) - columnTypeNames.reserveCapacity(numFields) - columnIsBinary.reserveCapacity(numFields) - columnIsBoolean.reserveCapacity(numFields) - columnMeta.reserveCapacity(numFields) let sessionFlavor = flavor - - if let fields = mysql_fetch_fields(resultPtr) { - for i in 0.., metadata: UnsafeMutablePointer, - columns: [String], - columnTypes: [UInt32], - columnTypeNames: [String], - columnIsBinary: [Bool], + columns: MySQLResultColumns, rowCap: Int? = nil, generation: Int, sentAt: Date @@ -950,8 +811,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { break } - // Re-fetch truncated columns with correctly sized buffers if fetchStatus == MYSQL_DATA_TRUNCATED { + var grewBuffer = false for i in 0.. Int(resultBinds[i].buffer_length) { @@ -962,33 +823,22 @@ final class MariaDBPluginConnection: @unchecked Sendable { resultBuffers[i] = newBuffer resultBinds[i].buffer = newBuffer resultBinds[i].buffer_length = UInt(actualLength) + grewBuffer = true if mysql_stmt_fetch_column(stmt, &resultBinds[i], UInt32(i), 0) != 0 { logger.warning("mysql_stmt_fetch_column failed for column \(i)") } } } - } - - var row: [PluginCellValue] = [] - for i in 0..= batchSize { continuation.yield(.rows(batch)) batch.removeAll(keepingCapacity: true) @@ -1296,77 +1071,41 @@ final class MariaDBPluginConnection: @unchecked Sendable { _cachedServerVersion } - private static func cellValue( - _ buffer: UnsafeRawBufferPointer, - typeRaw: UInt32, - isBinary: Bool, - isBoolean: Bool, - flavor: MySQLServerFlavor - ) -> PluginCellValue { - if flavor.isDatabend { - if isBoolean { - return .text(DatabendResultShape.booleanText(fromWireText: String(bytes: buffer, encoding: .utf8) ?? "")) - } - if isBinary { - return .bytes(DatabendResultShape.binaryValue(fromWireText: Data(buffer))) - } - if typeRaw == 255 { - return .text(String(bytes: buffer, encoding: .utf8) ?? "") - } - } - if typeRaw == 255 { - return .text(GeometryWKBParser.parse(buffer)) - } - if MariaDBFieldClassifier.isBit(typeRaw: typeRaw) { - return .text(MariaDBFieldClassifier.bitFieldToString(buffer)) - } - if isBinary { - return .bytes(Data(buffer)) - } - if let text = String(bytes: buffer, encoding: .utf8) { - return .text(text) - } - return .text(String(bytes: buffer, encoding: .isoLatin1) ?? "") - } - // MARK: - Private Helpers private func getError() -> MariaDBPluginError { guard let mysql = mysql else { return MariaDBPluginError.notConnected } + return readError(from: mysql) + } - let code = mysql_errno(mysql) - let message: String - if let msgPtr = mysql_error(mysql) { - message = String(cString: msgPtr) - } else { - message = "Unknown error" - } - - var sqlState: String? - if let statePtr = mysql_sqlstate(mysql), statePtr[0] != 0 { - sqlState = String(cString: statePtr) - } + private func getStmtError(_ stmt: UnsafeMutablePointer) -> MariaDBPluginError { + MariaDBPluginError( + code: mysql_stmt_errno(stmt), + message: mysql_stmt_error(stmt).map(decodedMessage) ?? "Unknown statement error", + sqlState: sqlState(mysql_stmt_sqlstate(stmt)) + ) + } - return MariaDBPluginError(code: code, message: message, sqlState: sqlState) + private func decodedMessage(_ message: UnsafePointer) -> String { + mysqlSessionText(cString: message, encoding: connectionEncoding) } - private func getStmtError(_ stmt: UnsafeMutablePointer) -> MariaDBPluginError { - let code = mysql_stmt_errno(stmt) - let message: String - if let msgPtr = mysql_stmt_error(stmt) { - message = String(cString: msgPtr) - } else { - message = "Unknown statement error" - } + private func sqlState(_ state: UnsafePointer?) -> String? { + guard let state, state[0] != 0 else { return nil } + return String(cString: state) + } - var sqlState: String? - if let statePtr = mysql_stmt_sqlstate(stmt), statePtr[0] != 0 { - sqlState = String(cString: statePtr) + private func textProtocolRow( + _ row: MYSQL_ROW, + lengths: UnsafeMutablePointer?, + columns: MySQLResultColumns + ) -> [PluginCellValue] { + columns.row(encoding: connectionEncoding) { index in + guard let value = row[index] else { return nil } + return UnsafeRawBufferPointer(start: value, count: Int(clamping: lengths?[index] ?? 0)) } - - return MariaDBPluginError(code: code, message: message, sqlState: sqlState) } } diff --git a/Plugins/MySQLDriverPlugin/MySQLCharacterSet.swift b/Plugins/MySQLDriverPlugin/MySQLCharacterSet.swift new file mode 100644 index 0000000000..96fa8f7834 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLCharacterSet.swift @@ -0,0 +1,111 @@ +// +// MySQLCharacterSet.swift +// MySQLDriverPlugin +// + +import CoreFoundation +import Foundation + +nonisolated internal struct MySQLCharacterSet: Hashable, Sendable { + static let utf8mb4 = MySQLCharacterSet(serverName: "utf8mb4") + static let latin1 = MySQLCharacterSet(serverName: "latin1") + + let name: String + private let decoding: Decoding + + init(serverName: String) { + let normalized = serverName.trimmingCharacters(in: .whitespaces).lowercased() + name = normalized == "utf8" ? "utf8mb3" : normalized + decoding = Self.decoding(forName: name) + } + + func decode(_ bytes: UnsafeRawBufferPointer) -> String { + switch decoding { + case .utf8: + return Self.decodeUTF8ReplacingInvalid(bytes) + case .utf8OrMySQLLatin1: + return Self.decodeUTF8OrMySQLLatin1(bytes) + case .foundation(let encoding, let isSingleByte): + return Self.decode(bytes, encoding: encoding, isSingleByte: isSingleByte) + } + } + + static func decodeUTF8OrMySQLLatin1(_ bytes: UnsafeRawBufferPointer) -> String { + if let text = String(bytes: bytes, encoding: .utf8) { + return text + } + return MySQLLatin1.decode(bytes) + } + + static func decodeUTF8ReplacingInvalid(_ bytes: UnsafeRawBufferPointer) -> String { + String(decoding: bytes, as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + } + + static var singleByteDecodedNames: [String] { + Array(singleByteEncodings.keys).sorted() + } + + static var multiByteDecodedNames: [String] { + Array(multiByteEncodings.keys).sorted() + } + + private enum Decoding: Hashable, Sendable { + case utf8 + case utf8OrMySQLLatin1 + case foundation(String.Encoding, isSingleByte: Bool) + } + + private static let utf8Names: Set = ["utf8mb4", "utf8mb3", "ascii", "binary"] + + private static func decoding(forName name: String) -> Decoding { + if utf8Names.contains(name) { return .utf8 } + if name == "latin1" { return .utf8OrMySQLLatin1 } + if let encoding = singleByteEncodings[name] { return .foundation(encoding, isSingleByte: true) } + if let encoding = multiByteEncodings[name] { return .foundation(encoding, isSingleByte: false) } + return .utf8 + } + + private static let singleByteEncodings: [String: String.Encoding] = [ + "latin2": .isoLatin2, + "cp1250": .windowsCP1250, + "cp1251": .windowsCP1251, + "cp1256": encoding(.windowsArabic), + "cp1257": encoding(.windowsBalticRim), + "cp850": encoding(.dosLatin1), + "cp852": encoding(.dosLatin2), + "latin5": encoding(.isoLatin5), + "macce": encoding(.macCentralEurRoman), + "macroman": .macOSRoman + ] + + private static let multiByteEncodings: [String: String.Encoding] = [ + "cp932": .shiftJIS, + "gbk": encoding(.GBK_95), + "gb2312": encoding(.EUC_CN), + "gb18030": encoding(.GB_18030_2000), + "ucs2": .utf16BigEndian, + "utf16": .utf16BigEndian, + "utf16le": .utf16LittleEndian, + "utf32": .utf32BigEndian + ] + + private static func encoding(_ encoding: CFStringEncodings) -> String.Encoding { + String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(encoding.rawValue))) + } + + private static func decode( + _ bytes: UnsafeRawBufferPointer, + encoding: String.Encoding, + isSingleByte: Bool + ) -> String { + if let text = String(bytes: bytes, encoding: encoding) { + return text + } + guard isSingleByte else { + return decodeUTF8ReplacingInvalid(bytes) + } + return bytes.reduce(into: "") { text, byte in + text += String(bytes: [byte], encoding: encoding) ?? "\u{FFFD}" + } + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLColumnDecoding.swift b/Plugins/MySQLDriverPlugin/MySQLColumnDecoding.swift new file mode 100644 index 0000000000..694924873f --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLColumnDecoding.swift @@ -0,0 +1,102 @@ +// +// MySQLColumnDecoding.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +nonisolated internal enum MySQLColumnDecoding: Equatable, Sendable { + case geometry + case bit + case bytes + case text(MySQLCharacterSet) + case databendBoolean + case databendHexBytes + + private static let geometryType: UInt32 = 255 + + init( + typeRaw: UInt32, + length: UInt = 0, + charsetnr: UInt32, + characterSetName: String?, + flavor: MySQLServerFlavor = .mysql + ) { + let isBinary = MariaDBFieldClassifier.isBinary(typeRaw: typeRaw, charset: charsetnr) + if flavor.isDatabend, DatabendResultShape.isBoolean(typeRaw: typeRaw, length: length) { + self = .databendBoolean + } else if flavor.isDatabend, isBinary { + self = .databendHexBytes + } else if typeRaw == Self.geometryType { + self = flavor.isDatabend ? .text(.utf8mb4) : .geometry + } else if MariaDBFieldClassifier.isBit(typeRaw: typeRaw) { + self = .bit + } else if isBinary { + self = .bytes + } else if charsetnr == mysqlBinaryCharset { + self = .text(.utf8mb4) + } else { + self = .text(characterSetName.map(MySQLCharacterSet.init(serverName:)) ?? .utf8mb4) + } + } + + func decode(_ bytes: UnsafeRawBufferPointer, encoding: MySQLConnectionEncoding) -> PluginCellValue { + switch self { + case .geometry: + return .text(GeometryWKBParser.parse(bytes)) + case .bit: + return .text(MariaDBFieldClassifier.bitFieldToString(bytes)) + case .bytes: + return .bytes(Data(bytes)) + case .text(let characterSet): + return .text(encoding.presentedText(characterSet.decode(bytes))) + case .databendBoolean: + return .text(DatabendResultShape.booleanText(fromWireText: MySQLCharacterSet.utf8mb4.decode(bytes))) + case .databendHexBytes: + return .bytes(DatabendResultShape.binaryValue(fromWireText: Data(bytes))) + } + } +} + +nonisolated internal struct MySQLResultColumns { + private(set) var names: [String] = [] + private(set) var typeCodes: [UInt32] = [] + private(set) var typeNames: [String] = [] + private(set) var decodings: [MySQLColumnDecoding] = [] + private(set) var metadata: [PluginColumnInfo] = [] + + var count: Int { names.count } + + mutating func append( + name: String, + typeCode: UInt32, + typeName: String, + decoding: MySQLColumnDecoding, + flags: UInt + ) { + names.append(name) + typeCodes.append(typeCode) + typeNames.append(typeName) + decodings.append(decoding) + metadata.append(makeColumnMeta(name: name, typeName: typeName, flags: flags)) + } + + func row( + encoding: MySQLConnectionEncoding, + value: (Int) -> UnsafeRawBufferPointer? + ) -> [PluginCellValue] { + decodings.indices.map { index in + guard let bytes = value(index) else { return .null } + return decodings[index].decode(bytes, encoding: encoding) + } + } +} + +nonisolated internal func mysqlSessionText(_ bytes: UnsafeRawBufferPointer, encoding: MySQLConnectionEncoding) -> String { + encoding.presentedText(MySQLCharacterSet.decodeUTF8OrMySQLLatin1(bytes)) +} + +nonisolated internal func mysqlSessionText(cString: UnsafePointer, encoding: MySQLConnectionEncoding) -> String { + mysqlSessionText(UnsafeRawBufferPointer(start: cString, count: strlen(cString)), encoding: encoding) +} diff --git a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift index ed0adf664a..46e2a5b6ce 100644 --- a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift @@ -6,12 +6,12 @@ import Foundation import TableProPluginKit -internal func mysqlQuoteIdentifier(_ name: String) -> String { +nonisolated internal func mysqlQuoteIdentifier(_ name: String) -> String { let escaped = name.replacingOccurrences(of: "`", with: "``") return "`\(escaped)`" } -internal func mysqlEscapeStringLiteral(_ value: String) -> String { +nonisolated internal func mysqlEscapeStringLiteral(_ value: String) -> String { var result = value result = result.replacingOccurrences(of: "\\", with: "\\\\") result = result.replacingOccurrences(of: "'", with: "''") diff --git a/Plugins/MySQLDriverPlugin/MySQLConnectionEncoding.swift b/Plugins/MySQLDriverPlugin/MySQLConnectionEncoding.swift new file mode 100644 index 0000000000..6702122977 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLConnectionEncoding.swift @@ -0,0 +1,54 @@ +// +// MySQLConnectionEncoding.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +nonisolated internal enum MySQLConnectionEncoding: String, CaseIterable, Sendable { + case utf8 = "" + case utf8ViaLatin1 + + static let fieldId = "mysqlConnectionEncoding" + static let sessionCharacterSetName = "utf8mb4" + static let sessionFallbackStatement = "SET NAMES utf8" + + static var connectionField: ConnectionField { + ConnectionField( + id: fieldId, + label: String(localized: "Encoding"), + fieldType: .dropdown(options: allCases.map { .init(value: $0.rawValue, label: $0.displayName) }), + section: .advanced + ) + } + + init(fieldValue: String?) { + self = fieldValue.flatMap(Self.init(rawValue:)) ?? .utf8 + } + + init(additionalFields: [String: String]) { + self.init(fieldValue: additionalFields[Self.fieldId]) + } + + var displayName: String { + switch self { + case .utf8: return "UTF-8" + case .utf8ViaLatin1: return String(localized: "UTF-8 via Latin 1") + } + } + + var sessionStatements: [String] { + switch self { + case .utf8: return [] + case .utf8ViaLatin1: return ["SET character_set_client = latin1"] + } + } + + func presentedText(_ text: String) -> String { + switch self { + case .utf8: return text + case .utf8ViaLatin1: return MySQLLatin1.repairingDoubleEncodedUTF8(text) + } + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift b/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift new file mode 100644 index 0000000000..2eaa49764e --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift @@ -0,0 +1,33 @@ +import Foundation + +internal enum MySQLKillTarget: Equatable, Sendable { + case threadId + case tidbConnection(UInt64) + case databendSession(String) + + func statement(threadId: UInt) -> String? { + switch self { + case .threadId: + return threadId > 0 ? "KILL QUERY \(threadId)" : nil + case .tidbConnection(let id): + return "KILL TIDB QUERY \(id)" + case .databendSession(let session): + return "KILL QUERY '\(mysqlEscapeStringLiteral(session))'" + } + } +} + +internal extension MySQLServerFlavor { + func killTarget(connectionIdentifier: String?) -> MySQLKillTarget { + switch self { + case .tidb: + guard let id = connectionIdentifier.flatMap(UInt64.init) else { return .threadId } + return .tidbConnection(id) + case .databend: + guard let session = connectionIdentifier, !session.isEmpty else { return .threadId } + return .databendSession(session) + case .mysql, .mariadb, .oceanbase: + return .threadId + } + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLLatin1.swift b/Plugins/MySQLDriverPlugin/MySQLLatin1.swift new file mode 100644 index 0000000000..becef4793c --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLLatin1.swift @@ -0,0 +1,64 @@ +// +// MySQLLatin1.swift +// MySQLDriverPlugin +// + +import Foundation + +nonisolated internal enum MySQLLatin1 { + private static let windowsRangeScalars: [UInt32] = [ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, + 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178 + ] + + private static let windowsRange = 0x80...0x9F + + static let scalarsByByte: [Unicode.Scalar] = (0...255).map { byte in + guard windowsRange.contains(byte), + let scalar = Unicode.Scalar(windowsRangeScalars[byte - windowsRange.lowerBound]) else { + return Unicode.Scalar(UInt8(byte)) + } + return scalar + } + + private static let bytesByScalar: [UInt32: UInt8] = Dictionary( + uniqueKeysWithValues: scalarsByByte.enumerated().map { ($0.element.value, UInt8($0.offset)) } + ) + + static func decode(_ bytes: UnsafeRawBufferPointer) -> String { + guard bytes.contains(where: { $0 >= 0x80 }) else { + return MySQLCharacterSet.decodeUTF8ReplacingInvalid(bytes) + } + var scalars = String.UnicodeScalarView() + scalars.reserveCapacity(bytes.count) + for byte in bytes { + scalars.append(scalarsByByte[Int(byte)]) + } + return String(scalars) + } + + static func decode(_ bytes: [UInt8]) -> String { + bytes.withUnsafeBytes { decode($0) } + } + + static func bytes(representing text: String) -> [UInt8]? { + var bytes: [UInt8] = [] + bytes.reserveCapacity(text.utf8.count) + for scalar in text.unicodeScalars { + guard let byte = bytesByScalar[scalar.value] else { return nil } + bytes.append(byte) + } + return bytes + } + + static func repairingDoubleEncodedUTF8(_ text: String) -> String { + guard text.utf8.contains(where: { $0 >= 0x80 }) else { return text } + guard let original = bytes(representing: text), + let repaired = String(bytes: original, encoding: .utf8) else { + return text + } + return repaired + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift b/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift new file mode 100644 index 0000000000..117acf7b95 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift @@ -0,0 +1,95 @@ +// +// MySQLMaintenance.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// MySQL's maintenance operations and the statements they produce. +/// +/// Pure, so the confirmation sheet's preview and the statement that runs are one function rather +/// than two implementations that drifted: the sheet printed `CHECK TABLE orders MEDIUM` with the +/// name unquoted where `CHECK TABLE \`orders\` MEDIUM` ran. +/// +/// The kind sets come from the MySQL reference rather than from a probe, because no MySQL server was +/// available: `OPTIMIZE TABLE`, `ANALYZE TABLE` and `REPAIR TABLE` are documented on base tables and +/// partitioned tables, while `CHECK TABLE` is documented to check views as well, for references in +/// the view body to tables that no longer exist. +nonisolated internal enum MySQLMaintenance { + internal static let optimize = "OPTIMIZE TABLE" + internal static let analyze = "ANALYZE TABLE" + internal static let check = "CHECK TABLE" + internal static let repair = "REPAIR TABLE" + + internal static let modeKey = "mode" + internal static let checkModes = ["QUICK", "FAST", "MEDIUM", "EXTENDED", "CHANGED"] + internal static let defaultCheckMode = "MEDIUM" + + internal static let analyzeOperation = PluginMaintenanceOperation( + name: analyze, + appliesTo: [.table, .partitionedTable], + scope: .object + ) + + internal static let operations: [PluginMaintenanceOperation] = [ + PluginMaintenanceOperation( + name: optimize, + appliesTo: [.table, .partitionedTable], + scope: .object + ), + analyzeOperation, + PluginMaintenanceOperation( + name: check, + appliesTo: [.table, .partitionedTable, .view], + scope: .object, + options: [ + PluginMaintenanceOption( + key: modeKey, + label: String(localized: "Check mode:"), + defaultValue: defaultCheckMode, + choices: checkModes + ) + ] + ), + PluginMaintenanceOperation( + name: repair, + appliesTo: [.table, .partitionedTable], + scope: .object + ) + ] + + /// Every MySQL maintenance statement names one table, so a nil one produces nothing rather than a + /// database-wide form the engine does not have. + /// + /// The mode is matched against the declared choices instead of being interpolated as it arrives. + /// It reaches here from an MCP client as well as from the sheet's picker, and it lands in the + /// statement text unquoted. + internal static func statements( + operation: String, + table: String?, + schema: String?, + options: [String: String], + flavor: MySQLServerFlavor + ) -> [String]? { + guard let table, flavor.maintenanceOperations.contains(where: { $0.name == operation }) else { return nil } + let target = qualified(table: table, schema: schema, flavor: flavor) + switch operation { + case optimize, analyze, repair: + return ["\(operation) \(target)"] + case check: + let mode = checkModes.first { $0 == options[modeKey] } ?? defaultCheckMode + return ["\(check) \(target) \(mode)"] + default: + return nil + } + } + + private static func qualified(table: String, schema: String?, flavor: MySQLServerFlavor) -> String { + func quote(_ name: String) -> String { + flavor.isDatabend ? DatabendCatalog.quoteIdentifier(name) : mysqlQuoteIdentifier(name) + } + guard let schema, !schema.isEmpty else { return quote(table) } + return "\(quote(schema)).\(quote(table))" + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 33f95710de..aab80c5f16 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -15,7 +15,7 @@ import TableProPluginKit final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "MySQL Driver" static let pluginVersion = "1.0.0" - static let pluginDescription = "MySQL, MariaDB, TiDB, and Databend support via libmariadb" + static let pluginDescription = "MySQL, MariaDB, TiDB, Databend, and OceanBase support via libmariadb" static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "MySQL" @@ -30,9 +30,10 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { defaultValue: "0", fieldType: .stepper(range: ConnectionField.IntRange(0...240)), section: .advanced - ) + ), + MySQLConnectionEncoding.connectionField ] - static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend"] + static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend", "OceanBase"] // MARK: - UI/Capability Metadata @@ -122,7 +123,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static func driverVariant(for databaseTypeId: String) -> String? { switch databaseTypeId { - case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant: + case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant, MySQLServerFlavor.oceanbaseVariant: return databaseTypeId default: return nil diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift index 02d63b71cd..a4179cd257 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift @@ -10,6 +10,7 @@ internal struct MySQLFlavorMismatchError: Error, Equatable { enum Kind: Equatable { case databendNeedsItsOwnType case notDatabend + case notOceanBase } let kind: Kind @@ -22,13 +23,22 @@ extension MySQLFlavorMismatchError: PluginDriverError { return String(localized: "This server is Databend. Edit the connection and choose Databend as its type.") case .notDatabend: return String(localized: "This server did not identify as Databend. Check the host and port of its MySQL handler.") + case .notOceanBase: + return String(localized: "This server did not identify as OceanBase. Check the host and port of its MySQL mode tenant.") } } } extension MySQLPluginDriver { static func initialFlavor(for config: DriverConnectionConfig) -> MySQLServerFlavor { - config.additionalFields["driverVariant"] == MySQLServerFlavor.databendVariant ? .databend : .mysql + switch config.additionalFields["driverVariant"] { + case MySQLServerFlavor.databendVariant: + return .databend + case MySQLServerFlavor.oceanbaseVariant: + return .oceanbase(version: nil) + default: + return .mysql + } } func resolveFlavor(on connection: MariaDBPluginConnection, variant: String?) async throws -> MySQLServerFlavor { @@ -48,6 +58,10 @@ extension MySQLPluginDriver { throw MySQLFlavorMismatchError(kind: .databendNeedsItsOwnType) } + if variant == MySQLServerFlavor.oceanbaseVariant { + return try await oceanbaseFlavor(on: connection) + } + guard MySQLFlavorResolution.needsTiDBVersionProbe(banner: banner, variant: variant) else { return bannerFlavor } @@ -58,6 +72,26 @@ extension MySQLPluginDriver { return .tidb(version: version) } + /// The handshake banner is a plain MySQL version on OceanBase, so `@@version_comment` is the only + /// thing that names the engine and the connection cannot be confirmed without it. A server that + /// answers with another engine's comment is refused; a probe that does not answer at all is not + /// evidence of anything, and failing there would turn one unlucky reconnect into a connection the + /// user cannot reopen. + private func oceanbaseFlavor(on connection: MariaDBPluginConnection) async throws -> MySQLServerFlavor { + let comment: String + do { + comment = try await connection.executeQuery(MySQLFlavorResolution.oceanbaseProbe) + .rows.first?.first?.asText ?? "" + } catch { + Self.logger.debug("OceanBase probe failed: \(error.localizedDescription, privacy: .public)") + return .oceanbase(version: nil) + } + guard MySQLServerFlavor.namesOceanBase(comment) else { + throw MySQLFlavorMismatchError(kind: .notOceanBase) + } + return .oceanbase(version: MySQLServerFlavor.oceanbaseVersion(fromVersionComment: comment)) + } + func killTarget(for flavor: MySQLServerFlavor, on connection: MariaDBPluginConnection) async -> MySQLKillTarget { guard flavor.isTiDB || flavor.isDatabend else { return .threadId } let identifier = await firstValue(of: MySQLFlavorResolution.connectionIdentifierProbe, on: connection) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 97eaf4ba58..6ca4c71ae8 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -17,7 +17,12 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// The database a metadata read is scoped to. MySQL has no schema level, so this is what a /// caller means by "schema" everywhere in the catalog queries. - var activeDatabaseName: String { _activeDatabase } + /// + /// Guarded by `sessionLock`, because `switchDatabase` writes it from whichever task made the + /// switch and `connect()` reads it from the reacquire task to decide what to reconnect to. + var activeDatabaseName: String { + sessionLock.withLock { _activeDatabase } + } internal var cachedPrivilegeCatalog: PluginPrivilegeCatalog? @@ -25,10 +30,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var flavor: MySQLServerFlavor { sessionLock.withLock { _flavor } } - /// What the session is holding that a reconnect would destroy. Tracked from the statements - /// that go through the driver, because MySQL will not answer the question: measured on 8.4.11, - /// an ordinary user is refused on every table that would report its own temporary tables, user - /// variables, locks or transaction. + /// What the session is holding that a reconnect would destroy. The open transaction comes + /// from the server's status flags; the rest is read from the statements that go through the + /// driver, because MySQL will not answer those: measured on 8.4.11, an ordinary user is + /// refused on every table that would report its own temporary tables, variables or locks. private var footprint = MySQLSessionFootprint() /// Set by `applyQueryTimeout` so any reconnect can put it back. The server forgets it, and a @@ -121,10 +126,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { port: config.port, user: config.username, password: config.password, - database: _activeDatabase, + database: activeDatabaseName, sslConfig: sslConfig, enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true", - queryTimeoutSeconds: config.additionalFields["queryTimeoutSeconds"].flatMap { Int($0) } ?? 0 + queryTimeoutSeconds: config.additionalFields["queryTimeoutSeconds"].flatMap { Int($0) } ?? 0, + connectionEncoding: MySQLConnectionEncoding(additionalFields: config.additionalFields) ) try await conn.connect() @@ -188,7 +194,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func ping() async throws { guard !sessionLock.withLock({ isReleased }) else { return } let conn = try requireLiveConnection() - defer { endOperation() } + defer { endOperation(on: conn) } _ = try await conn.executeQuery("SELECT 1", rowCap: nil) } @@ -214,7 +220,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await executeWithReconnect(query: query, isRetry: false, rowCap: cap) } let conn = try await requireConnection() - defer { endOperation() } + defer { endOperation(on: conn) } noteActivity(query) let startTime = Date() let result = try await conn.executeParameterizedQuery(query, parameters: parameters, rowCap: cap) @@ -240,7 +246,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { let conn = try await requireConnection() - defer { endOperation() } + defer { endOperation(on: conn) } noteActivity(query) let startTime = Date() @@ -265,10 +271,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } /// The reconnect this does is not the idle release: it is recovery from a connection the - /// server dropped, where the session state is already gone. `mysqlStatementIsSafeToReplay` - /// decides whether the statement can be run twice, and the footprint decides whether running - /// it again would land it somewhere different. Replaying a statement outside the transaction - /// the user opened for it commits work they meant to be able to roll back. + /// server dropped, where the session state is already gone. `mysqlMayReplay` owns the + /// decision, and takes both halves of it: whether the statement means the same thing run + /// twice, and whether the session that replaces this one can answer it the same way. private func executeWithReconnect( query: String, isRetry: Bool, @@ -278,7 +283,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let startTime = Date() let conn = try await requireConnection() - defer { endOperation() } + defer { endOperation(on: conn) } if countsAsActivity { noteActivity(query) } @@ -318,8 +323,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnMeta: result.columnMeta ) } catch let error as MariaDBPluginError - where !isRetry && isConnectionLostError(error) && mysqlStatementIsSafeToReplay(query) - && !hasOpenTransaction { + where !isRetry && isConnectionLostError(error) && mayReplay(query) { try await reconnect() return try await executeWithReconnect( query: query, @@ -343,8 +347,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - private var hasOpenTransaction: Bool { - sessionLock.withLock { footprint.hasOpenTransaction } + private func mayReplay(_ query: String) -> Bool { + sessionLock.withLock { mysqlMayReplay(query, on: footprint) } } /// Takes a server connection again if the last one was handed back. A connection that was @@ -376,8 +380,14 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - private func endOperation() { - sessionLock.withLock { activeOperations = max(0, activeOperations - 1) } + /// The server's answer about the transaction arrives with the reply to the statement, so it + /// is taken where the statement is handed back rather than guessed from the text. + private func endOperation(on conn: MariaDBPluginConnection) { + let isInTransaction = conn.isInTransaction + sessionLock.withLock { + footprint.observeServerTransaction(isOpen: isInTransaction) + activeOperations = max(0, activeOperations - 1) + } } /// Concurrent callers wait on the one attempt rather than each starting their own. A metadata @@ -555,7 +565,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let query = """ SELECT COLUMN_NAME, GENERATION_EXPRESSION FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = \'\(mysqlEscapeStringLiteral(_activeDatabase))\' + WHERE TABLE_SCHEMA = \'\(mysqlEscapeStringLiteral(activeDatabaseName))\' AND TABLE_NAME = \'\(mysqlEscapeStringLiteral(table))\' AND GENERATION_EXPRESSION <> \'\' """ @@ -580,7 +590,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return [] } guard !flavor.isTiDB else { return try await tidbCheckConstraints(table: table) } - let database = mysqlEscapeStringLiteral(_activeDatabase) + let database = mysqlEscapeStringLiteral(activeDatabaseName) let safeTable = mysqlEscapeStringLiteral(table) let query: String if flavor.isMariaDB { @@ -617,7 +627,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// the bulk read reports a changed generation expression as no difference at all. func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { guard !flavor.isDatabend else { return try await databendAllColumns() } - let dbName = _activeDatabase + let dbName = activeDatabaseName let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let hasGenerationExpression = MySQLServerVersion.hasGenerationExpression( banner: _serverVersion, flavor: flavor @@ -707,7 +717,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { guard !flavor.isDatabend else { return [] } - let dbName = _activeDatabase + let dbName = activeDatabaseName let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let escapedTable = table.replacingOccurrences(of: "'", with: "''") @@ -754,7 +764,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// The same builder the schema-wide list uses, with one more predicate. func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { guard !flavor.isDatabend else { return [] } - let dbName = schema?.isEmpty == false ? (schema ?? _activeDatabase) : _activeDatabase + let dbName = schema?.isEmpty == false ? (schema ?? activeDatabaseName) : activeDatabaseName let triggers = try await triggerList(schema: dbName, table: table) Self.logger.info("[trigger] mysql fetchTriggers db=\(dbName, privacy: .public) table=\(table, privacy: .public) parsed=\(triggers.count)") return triggers @@ -781,7 +791,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { guard !flavor.isDatabend else { return [:] } - let dbName = _activeDatabase + let dbName = activeDatabaseName let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let query = """ @@ -826,7 +836,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { - let dbName = _activeDatabase + let dbName = activeDatabaseName let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") let escapedTable = table.replacingOccurrences(of: "'", with: "''") @@ -924,7 +934,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let task = Task { do { let conn = try await requireConnection() - defer { self.endOperation() } + defer { self.endOperation(on: conn) } noteActivity(query) for try await element in conn.streamQuery(query) { continuation.yield(element) @@ -1018,9 +1028,25 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Database Switching + /// The `USE` goes in the way the query timeout does, as the driver's own setup rather than as + /// use. `_activeDatabase` is what every reconnect connects to, so the database the driver + /// switched to is not the session's to lose, and counting it would leave the footprint dirty + /// from the first database switch onward. A `USE` the user types is a different statement: the + /// driver does not know about it, a reconnect silently undoes it, and the footprint says so. + /// + /// The clock still moves, because a database switch is the user using the connection: leaving + /// it alone let the idle timer fire seconds after a switch and charge the next click a full + /// reconnect. func switchDatabase(to database: String) async throws { - _ = try await execute(query: "USE \(quoteIdentifier(database))") - _activeDatabase = database + _ = try await executeWithReconnect( + query: "USE \(quoteIdentifier(database))", + isRetry: false, + countsAsActivity: false + ) + sessionLock.withLock { + _activeDatabase = database + lastActivity = ContinuousClock.now + } } // MARK: - Query Timeout @@ -1052,21 +1078,21 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Maintenance func supportedMaintenanceOperations() -> [String]? { + flavor.maintenanceOperations.map(\.name) + } + + func maintenanceOperations() -> [PluginMaintenanceOperation]? { flavor.maintenanceOperations } func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { - guard let table, flavor.maintenanceOperations.contains(operation) else { return nil } - let quoted = quoteIdentifier(table) - switch operation { - case "OPTIMIZE TABLE": return ["OPTIMIZE TABLE \(quoted)"] - case "ANALYZE TABLE": return ["ANALYZE TABLE \(quoted)"] - case "CHECK TABLE": - let mode = options["mode"] ?? "MEDIUM" - return ["CHECK TABLE \(quoted) \(mode)"] - case "REPAIR TABLE": return ["REPAIR TABLE \(quoted)"] - default: return nil - } + MySQLMaintenance.statements( + operation: operation, + table: table, + schema: schema, + options: options, + flavor: flavor + ) } // MARK: - Create Table DDL diff --git a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index de8c018da8..2bfcbfb077 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -internal struct MySQLEngineVersion: Comparable, Sendable { +nonisolated internal struct MySQLEngineVersion: Comparable, Sendable { let major: Int let minor: Int let patch: Int @@ -28,14 +28,16 @@ internal struct MySQLEngineVersion: Comparable, Sendable { } } -internal enum MySQLServerFlavor: Equatable, Sendable { +nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { case mysql case mariadb case tidb(version: MySQLEngineVersion?) case databend + case oceanbase(version: MySQLEngineVersion?) static let tidbVariant = "TiDB" static let databendVariant = "Databend" + static let oceanbaseVariant = "OceanBase" static func fromBanner(_ banner: String?) -> MySQLServerFlavor { guard let banner else { return .mysql } @@ -63,6 +65,19 @@ internal enum MySQLServerFlavor: Equatable, Sendable { banner.range(of: #"^\d+\.\d+\.\d+-v\d+\.\d+\.\d+-"#, options: .regularExpression) != nil } + /// The MySQL handshake banner is OceanBase's `_display_mysql_version`, which is `5.7.25` on a + /// direct connection and `5.6.25` through OBProxy: it never names the engine. `@@version_comment` + /// is what does, as `OceanBase_CE 4.4.2.1 (r...)` or `OceanBase 3.1.3 (r...)`. + static func namesOceanBase(_ versionComment: String) -> Bool { + versionComment.range(of: "oceanbase", options: .caseInsensitive) != nil + } + + static func oceanbaseVersion(fromVersionComment comment: String) -> MySQLEngineVersion? { + guard let name = comment.range(of: "OceanBase", options: .caseInsensitive) else { return nil } + let rest = comment[name.upperBound...].drop { $0.isLetter || $0 == "_" || $0 == "-" || $0.isWhitespace } + return MySQLEngineVersion(parsing: rest) + } + var isMariaDB: Bool { self == .mariadb } var isTiDB: Bool { @@ -85,15 +100,17 @@ internal enum MySQLServerFlavor: Equatable, Sendable { return ["INFORMATION_SCHEMA", "METRICS_SCHEMA", "PERFORMANCE_SCHEMA", "mysql", "sys"] case .databend: return ["information_schema", "system"] + case .oceanbase: + return ["information_schema", "mysql", "oceanbase"] } } - var maintenanceOperations: [String] { + var maintenanceOperations: [PluginMaintenanceOperation] { switch self { case .mysql, .mariadb: - return ["OPTIMIZE TABLE", "ANALYZE TABLE", "CHECK TABLE", "REPAIR TABLE"] - case .tidb, .databend: - return ["ANALYZE TABLE"] + return MySQLMaintenance.operations + case .tidb, .databend, .oceanbase: + return [MySQLMaintenance.analyzeOperation] } } @@ -108,12 +125,29 @@ internal enum MySQLServerFlavor: Equatable, Sendable { return mode == .readWrite ? "START TRANSACTION READ WRITE" : "START TRANSACTION" } + /// OceanBase enforces `ob_query_timeout` of its own, 10 seconds by default, and + /// `max_execution_time` governs read-only statements alone: measured on 4.4.2.1, an `UPDATE` + /// under a 30 second `max_execution_time` still failed at 10 seconds with error 4012. So the + /// setting has to move OceanBase's own limit, in microseconds, whatever its value. Zero is the + /// setting's "no limit", which an export relies on, and the server clamps what it accepts + /// there; this is what it clamps to. + static let oceanbaseUnlimitedQueryTimeoutMicroseconds = 3_216_672_000_000_000 + + static func oceanbaseQueryTimeoutStatement(seconds: Int) -> String { + let microseconds = seconds > 0 + ? seconds * 1_000_000 + : oceanbaseUnlimitedQueryTimeoutMicroseconds + return "SET SESSION max_execution_time = \(max(seconds, 0) * 1_000), ob_query_timeout = \(microseconds)" + } + func queryTimeoutStatement(seconds: Int) -> String { switch self { case .mariadb: return "SET SESSION max_statement_time = \(seconds)" case .databend: return "SET max_execute_time_in_seconds = \(seconds)" + case .oceanbase: + return Self.oceanbaseQueryTimeoutStatement(seconds: seconds) case .mysql, .tidb: return "SET SESSION max_execution_time = \(seconds * 1_000)" } @@ -137,39 +171,9 @@ internal enum MySQLServerFlavor: Equatable, Sendable { guard isDatabend else { return errno == 1_317 } return errno == 1_105 && message.contains("AbortedQuery") } - - func killTarget(connectionIdentifier: String?) -> MySQLKillTarget { - switch self { - case .tidb: - guard let id = connectionIdentifier.flatMap(UInt64.init) else { return .threadId } - return .tidbConnection(id) - case .databend: - guard let session = connectionIdentifier, !session.isEmpty else { return .threadId } - return .databendSession(session) - case .mysql, .mariadb: - return .threadId - } - } -} - -internal enum MySQLKillTarget: Equatable, Sendable { - case threadId - case tidbConnection(UInt64) - case databendSession(String) - - func statement(threadId: UInt) -> String? { - switch self { - case .threadId: - return threadId > 0 ? "KILL QUERY \(threadId)" : nil - case .tidbConnection(let id): - return "KILL TIDB QUERY \(id)" - case .databendSession(let session): - return "KILL QUERY '\(mysqlEscapeStringLiteral(session))'" - } - } } -internal enum MySQLFlavorResolution { +nonisolated internal enum MySQLFlavorResolution { static func needsTiDBVersionProbe(banner: String?, variant: String?) -> Bool { variant == MySQLServerFlavor.tidbVariant && !MySQLServerFlavor.fromBanner(banner).isTiDB } @@ -180,5 +184,6 @@ internal enum MySQLFlavorResolution { static let tidbVersionProbe = "SELECT tidb_version()" static let databendProbe = "SELECT value FROM system.settings WHERE name = 'max_result_rows'" + static let oceanbaseProbe = "SELECT @@version_comment" static let connectionIdentifierProbe = "SELECT CONNECTION_ID()" } diff --git a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift index 514dd1f9f0..e65763fc57 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift @@ -38,6 +38,9 @@ enum MySQLServerVersion { case .tidb(let version): guard let version else { return false } return version >= MySQLEngineVersion(major: 7, minor: 2, patch: 0) + case .oceanbase(let version): + guard let version else { return false } + return version >= MySQLEngineVersion(major: 4, minor: 0, patch: 0) case .databend: return false } @@ -53,6 +56,9 @@ enum MySQLServerVersion { return isAtLeast((10, 2, 0), banner: banner) case .tidb: return true + case .oceanbase(let version): + guard let version else { return false } + return version >= MySQLEngineVersion(major: 4, minor: 0, patch: 0) case .databend: return false } diff --git a/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift b/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift index b819ad1972..340773b1af 100644 --- a/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift +++ b/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift @@ -17,10 +17,11 @@ import TableProPluginKit /// statement, every `GET_LOCK` advisory lock and `LAST_INSERT_ID`, and rolls an open transaction /// back reporting success. /// -/// This is tracked from the statement text rather than asked of the server, which is the opposite -/// of what the DuckDB driver does, because MySQL will not answer the question. Measured on MySQL -/// 8.4.11, a user granted only its own database is refused on every table that would report this: -/// error 1142 on `performance_schema.user_variables_by_thread`, `prepared_statements_instances`, +/// The open transaction is the server's own answer, from the status flags it puts in every reply +/// (`observeServerTransaction`). The rest is read from the statement text, because MySQL will not +/// answer those. Measured on MySQL 8.4.11, a user granted only its own database is refused on +/// every table that would report them: error 1142 on +/// `performance_schema.user_variables_by_thread`, `prepared_statements_instances`, /// `metadata_locks` and `events_transactions_current`, and error 1227 on /// `information_schema.INNODB_TRX` and `INNODB_TEMP_TABLE_INFO`. Asking would therefore work only /// for a privileged user, and `GET_LOCK` has no enumeration for anybody. Reading the statements @@ -36,8 +37,15 @@ struct MySQLSessionFootprint: Equatable { private(set) var hasPreparedStatements = false private(set) var hasAdvisoryLocks = false private(set) var hasLockedTables = false + private(set) var hasOpenHandlers = false private(set) var hasSessionSettings = false + /// A `USE` the user ran themselves. The driver's own database switch does not come through + /// here, because it records the database it moved to and every reconnect connects to that one; + /// a `USE` typed into the editor does not, so a reconnect silently puts the session back on + /// the database the driver still thinks it is on. + private(set) var hasChangedDatabase = false + /// A `CALL` runs a body this driver never sees, and a routine is free to create a temporary /// table, take a lock or open a transaction. Opaque is the only honest reading. private(set) var ranOpaqueRoutine = false @@ -60,6 +68,9 @@ struct MySQLSessionFootprint: Equatable { if hasAdvisoryLocks { return String(localized: "This connection holds advisory locks, which reconnecting would release.") } + if hasOpenHandlers { + return String(localized: "This connection has open HANDLER cursors, which reconnecting would close.") + } if hasPreparedStatements { return String(localized: "This connection has prepared statements, which reconnecting would discard.") } @@ -69,6 +80,9 @@ struct MySQLSessionFootprint: Equatable { if hasSessionSettings { return String(localized: "This connection has session settings changed, which reconnecting would reset.") } + if hasChangedDatabase { + return String(localized: "This connection switched database with USE, which reconnecting would undo.") + } if ranOpaqueRoutine { return String(localized: "This connection called a stored routine, so TablePro cannot tell what the session is holding.") } @@ -76,16 +90,29 @@ struct MySQLSessionFootprint: Equatable { } mutating func observe(_ sql: String) { - switch SQLTransactionTracking.effect(of: sql) { + for statement in SQLStatementSplitting.statements(in: sql) { + let body = Self.executableBody(of: statement) + observeTransaction(body) + observeStatement(body) + } + } + + /// The server's own answer, taken from the status flags in its last reply, which is exact + /// where reading the statements is a guess. Measured on MySQL 8.4.11: `SET autocommit = 0` + /// followed by a plain `SELECT` reports a transaction that appears nowhere in the text, and so + /// do `/*!40101 BEGIN */` and `XA START 'x'`. It is applied after the statement has run, so + /// the text-derived guess is what stands until the reply arrives. + mutating func observeServerTransaction(isOpen: Bool) { + hasOpenTransaction = isOpen + } + + private mutating func observeTransaction(_ statement: String) { + switch SQLTransactionTracking.effect(of: statement) { case .opens: hasOpenTransaction = true case .closes: hasOpenTransaction = false case .unchanged: break @unknown default: hasOpenTransaction = true } - - for statement in SQLStatementSplitting.statements(in: sql) { - observeStatement(statement) - } } /// Forgets everything, for a reconnect the driver has decided is safe. Only the statements @@ -96,39 +123,55 @@ struct MySQLSessionFootprint: Equatable { private mutating func observeStatement(_ statement: String) { let normalized = statement.uppercased() - guard !normalized.isEmpty else { return } + let head = Self.collapsedHead(of: normalized) + guard !head.isEmpty else { return } - if normalized.hasPrefix("CREATE TEMPORARY ") || normalized.hasPrefix("CREATE OR REPLACE TEMPORARY ") { + if head.hasPrefix("CREATE TEMPORARY ") || head.hasPrefix("CREATE OR REPLACE TEMPORARY ") { hasTemporaryTables = true } /// Set, not cleared: a drop names one table and says nothing about the others, and a /// session with a temporary table left is still one a reconnect would damage. - if normalized.hasPrefix("DROP TEMPORARY ") { + if head.hasPrefix("DROP TEMPORARY ") { hasTemporaryTables = true } - if normalized.hasPrefix("PREPARE ") { + if head.hasPrefix("PREPARE ") { hasPreparedStatements = true } - if normalized.hasPrefix("DEALLOCATE ") { + if head.hasPrefix("DEALLOCATE ") { hasPreparedStatements = true } - if normalized.hasPrefix("LOCK TABLE") { + if head.hasPrefix("LOCK TABLE") { hasLockedTables = true } - if normalized.hasPrefix("UNLOCK TABLES") { + /// `FLUSH TABLES WITH READ LOCK` takes a global read lock that is the session's and + /// nothing else's, and the sessions that hold one are idle by design while a backup + /// copies files. Measured on MySQL 8.4.11: a writer got error 1205 while it was held, and + /// the same write went through the moment the holding connection was killed. + if head.hasPrefix("FLUSH "), Self.isFlushHoldingALock(normalized) { + hasLockedTables = true + } + if head.hasPrefix("UNLOCK TABLES") { hasLockedTables = false } - if normalized.hasPrefix("CALL ") { + /// Set, not cleared, for the same reason a dropped temporary table is: a `HANDLER ... CLOSE` + /// names one cursor. + if head.hasPrefix("HANDLER ") { + hasOpenHandlers = true + } + if head.hasPrefix("CALL ") { ranOpaqueRoutine = true } + if head.hasPrefix("USE ") { + hasChangedDatabase = true + } if normalized.contains("GET_LOCK(") { hasAdvisoryLocks = true } if normalized.contains("RELEASE_ALL_LOCKS(") { hasAdvisoryLocks = false } - if normalized.hasPrefix("SET ") { - observeSet(normalized) + if head.hasPrefix("SET ") { + observeSet(head) } /// `SELECT ... INTO @x` and `EXECUTE ... INTO @x` write a user variable without a leading /// `SET`, and `SELECT @x := 1` writes one without either. Both spellings lose the variable @@ -138,6 +181,55 @@ struct MySQLSessionFootprint: Equatable { } } + /// What MySQL runs when the statement opens with one of its version-gated comments, and the + /// statement itself otherwise. + /// + /// `/*!40101 SET NAMES utf8mb4 */` is executed by any server from 4.1.1, and MariaDB spells + /// its own `/*M!100301 ... */`. mysqldump writes its whole preamble this way, so a restore run + /// from the editor sets the character set, the time zone and eight `@OLD_` variables inside + /// them. `SQLStatementSplitting` leaves them whole rather than reading them as comments, + /// because only the engine that executes the body can say what it is. + /// + /// Whatever follows the comment is kept, so a line that carries a note after it, or a second + /// version-gated block, is still classified by the first thing the server would run. The + /// version number itself is not checked against the server: counting a statement the server + /// is too old to run holds a connection that is in fact clean, which is the safe direction. + private static func executableBody(of statement: String) -> String { + guard statement.hasPrefix("/*!") || statement.hasPrefix("/*M!") else { return statement } + guard let close = statement.range(of: "*/") else { return statement } + let marked = statement[statement.index(statement.startIndex, offsetBy: 2).. String { + normalized + .prefix(headLength) + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + } + + private static let headLength = 64 + + /// A `FLUSH` names its tables before the clause that matters, and a list of them runs past + /// the head, so this one reads the whole statement. No `FLUSH` is long enough for that to + /// cost anything. + private static func isFlushHoldingALock(_ normalized: String) -> Bool { + let collapsed = normalized.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + return collapsed.contains(" WITH READ LOCK") || collapsed.contains(" FOR EXPORT") + } + /// `SET` covers three different things: a user variable (`SET @x = 1`), a session setting /// (`SET SESSION sql_mode = ...`, the bare `SET sql_mode = ...` that means the same, and /// `SET NAMES` and `SET CHARACTER SET` under another spelling), and a global one, which @@ -145,7 +237,10 @@ struct MySQLSessionFootprint: Equatable { private mutating func observeSet(_ normalized: String) { let body = normalized.dropFirst("SET ".count).trimmingCharacters(in: .whitespaces) guard !body.hasPrefix("@@GLOBAL."), !body.hasPrefix("GLOBAL ") else { return } - if body.hasPrefix("@") { + /// `@@` is a system variable under another spelling, not a user variable: reporting + /// `SET @@SESSION.sql_mode` as "session variables set" blocks the release for the right + /// reason and tells the user the wrong one. + if body.hasPrefix("@"), !body.hasPrefix("@@") { hasUserVariables = true } else { hasSessionSettings = true diff --git a/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift b/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift index 84f31ba016..ccf55c9acc 100644 --- a/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift +++ b/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift @@ -23,6 +23,12 @@ internal func mysqlStatementIsReadOnly(_ query: String) -> Bool { /// re-run burns a second sequence value while the grid shows one, and `SELECT GET_LOCK(...)` takes /// the lock again. Neither reports anything: the retry looks like a connection that healed itself. /// +/// The last four change nothing and are here for the other half of the same problem: their answer +/// belongs to the session that ran the statement before them, and the replay runs on a new one. +/// Measured on MySQL 8.4.11, a fresh connection answers `SELECT LAST_INSERT_ID()` with `0`, so an +/// `INSERT` followed by a dropped connection and a replayed `SELECT LAST_INSERT_ID()` showed `0` +/// for a row that had an id. The footprint cannot cover these: an `INSERT` leaves it clean. +/// /// The scan is deliberately crude and only ever errs toward "do not replay", which costs the user a /// connection error where they would have got a transparent retry. It cannot see inside a stored /// function, so a `SELECT my_function()` that writes is still replayable. @@ -33,8 +39,22 @@ private let mysqlSideEffectingMarkers: [String] = [ "FOR UPDATE", "FOR SHARE", "LOCK IN SHARE MODE", "UUID_SHORT", "MASTER_POS_WAIT", "SOURCE_POS_WAIT", "BENCHMARK", "SLEEP", ":=", + "LAST_INSERT_ID", "ROW_COUNT", "FOUND_ROWS", "CONNECTION_ID", ] +/// Whether a statement the server dropped the connection under can be run again on the session +/// that replaces it. It takes both halves: the statement has to be one that means the same thing +/// twice, and the new session has to be able to answer it the same way. +/// +/// Only a session holding nothing can. Measured against MySQL 8.4.11 by killing the connection +/// and replaying: `SELECT @probe` answered `NULL` where it had answered 42, `SELECT DATABASE()` +/// answered the driver's own database over the one a `USE` had selected, and `@@SESSION.sql_mode` +/// came back as the server default over the session's `ANSI_QUOTES`. All three answered, none +/// raised, and the grid showed a value that was never true. +internal func mysqlMayReplay(_ query: String, on footprint: MySQLSessionFootprint) -> Bool { + footprint.isClean && mysqlStatementIsSafeToReplay(query) +} + internal func mysqlStatementIsSafeToReplay(_ query: String) -> Bool { guard mysqlStatementIsReadOnly(query) else { return false } let collapsed = query diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index 0b0de66a10..da17a5a58e 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/ParquetExportPlugin/Info.plist b/Plugins/ParquetExportPlugin/Info.plist index a44a0be525..9b0f410aba 100644 --- a/Plugins/ParquetExportPlugin/Info.plist +++ b/Plugins/ParquetExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds parquet diff --git a/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift index 4bba73bc63..6921f9c739 100644 --- a/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift @@ -57,11 +57,11 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let resolvedSchema = schema ?? core.currentSchema let query = """ SELECT table_name, table_type FROM information_schema.tables - WHERE table_schema = '\(schemaLiteral)' + WHERE table_schema = \(PostgreSQLObjectQueries.quoteLiteral(resolvedSchema)) ORDER BY table_name """ let result = try await execute(query: query) @@ -74,16 +74,18 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { - let safeTable = escapeLiteral(table) - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let query = Self.columnsQuery(schemaLiteral: schemaLiteral, tableFilter: "AND c.table_name = '\(safeTable)'") + let resolvedSchema = schema ?? core.currentSchema + let query = Self.columnsQuery( + schema: resolvedSchema, + tableFilter: "AND c.table_name = \(PostgreSQLObjectQueries.quoteLiteral(table))" + ) let result = try await execute(query: query) return result.rows.compactMap { Self.mapColumnRow($0, includesTableName: false) } } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let query = Self.columnsQuery(schemaLiteral: schemaLiteral, tableFilter: "", includesTableName: true) + let resolvedSchema = schema ?? core.currentSchema + let query = Self.columnsQuery(schema: resolvedSchema, tableFilter: "", includesTableName: true) let result = try await execute(query: query) var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { @@ -95,8 +97,7 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let quotedTable = quoteIdentifier(table) - let query = "SHOW INDEXES FROM \(quoteIdentifier(core.currentSchema)).\(quotedTable)" + let query = CockroachRelationSQL.showIndexes(table: table, schema: schema ?? core.currentSchema) let result = try await execute(query: query) guard let columnIndex = result.columns.firstIndex(of: "column_name"), @@ -116,14 +117,14 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { let columnName = row[columnIndex].asText else { continue } if let implicitIndex, implicitIndex < row.count, - row[implicitIndex].asText.map(Self.isTruthy) == true { + PostgreSQLCatalogBoolean.isTrue(row[implicitIndex].asText) { continue } if columnsByIndex[indexName] == nil { order.append(indexName) if let nonUniqueIndex, nonUniqueIndex < row.count { - uniqueByIndex[indexName] = row[nonUniqueIndex].asText.map(Self.isTruthy) == false + uniqueByIndex[indexName] = row[nonUniqueIndex].asText.map { !PostgreSQLCatalogBoolean.isTrue($0) } ?? false } else { uniqueByIndex[indexName] = false } @@ -144,53 +145,21 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { var tableDDLIncludesForeignKeys: Bool { true } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { - let safeTable = escapeLiteral(table) - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let query = """ - SELECT - tc.constraint_name, - kcu.column_name, - ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column, - rc.delete_rule, - rc.update_rule - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - AND tc.table_schema = rc.constraint_schema - JOIN information_schema.constraint_column_usage ccu - ON rc.unique_constraint_name = ccu.constraint_name - AND rc.unique_constraint_schema = ccu.table_schema - WHERE tc.table_name = '\(safeTable)' - AND tc.table_schema = '\(schemaLiteral)' - AND tc.constraint_type = 'FOREIGN KEY' - ORDER BY tc.constraint_name - """ - let result = try await execute(query: query) - return result.rows.compactMap { row -> PluginForeignKeyInfo? in - guard row.count >= 6, - let name = row[0].asText, - let column = row[1].asText, - let refTable = row[2].asText, - let refColumn = row[3].asText - else { return nil } - return PluginForeignKeyInfo( - name: name, - column: column, - referencedTable: refTable, - referencedColumn: refColumn, - onDelete: row[4].asText ?? "NO ACTION", - onUpdate: row[5].asText ?? "NO ACTION" + let query = PostgreSQLCatalogForeignKeys.query( + schema: schema ?? core.currentSchema, + table: table, + excludesPartitionClones: PostgreSQLCatalogForeignKeys.excludesPartitionClones( + serverVersionNumber: core.serverVersionNumber ) - } + ) + let result = try await execute(query: query) + return PostgreSQLCatalogForeignKeys.foreignKeys(from: result.rows.map { $0.map(\.asText) }) } func fetchTableDDL(table: String, schema: String?) async throws -> String { - let quotedTable = quoteIdentifier(table) - let result = try await execute(query: "SHOW CREATE TABLE \(quoteIdentifier(core.currentSchema)).\(quotedTable)") + let result = try await execute( + query: CockroachRelationSQL.showCreateTable(table: table, schema: schema ?? core.currentSchema) + ) guard let ddl = Self.createStatement(from: result) else { throw LibPQPluginError(message: "Failed to fetch DDL for table '\(table)'", sqlState: nil, detail: nil) } @@ -198,8 +167,9 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let quotedView = quoteIdentifier(view) - let result = try await execute(query: "SHOW CREATE VIEW \(quoteIdentifier(core.currentSchema)).\(quotedView)") + let result = try await execute( + query: CockroachRelationSQL.showCreateView(view: view, schema: schema ?? core.currentSchema) + ) guard let ddl = Self.createStatement(from: result) else { throw LibPQPluginError(message: "Failed to fetch definition for view '\(view)'", sqlState: nil, detail: nil) } @@ -223,11 +193,11 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - let escapedDb = escapeLiteral(database) + let databaseLiteral = PostgreSQLObjectQueries.quoteLiteral(database) let query = """ SELECT COUNT(*) FROM information_schema.tables - WHERE table_catalog = '\(escapedDb)' + WHERE table_catalog = \(databaseLiteral) AND table_schema NOT IN ('pg_catalog', 'information_schema', 'crdb_internal', 'pg_extension') """ let tableCount = (try? await execute(query: query)) @@ -259,10 +229,11 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - Query Helpers private static func columnsQuery( - schemaLiteral: String, + schema: String, tableFilter: String, includesTableName: Bool = false ) -> String { + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) let selectPrefix = includesTableName ? "c.table_name,\n" : "" let orderBy = includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position" return """ @@ -289,9 +260,9 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(schemaLiteral)' + AND tc.table_schema = \(schemaLiteral) ) pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name - WHERE c.table_schema = '\(schemaLiteral)' \(tableFilter) + WHERE c.table_schema = \(schemaLiteral) \(tableFilter) ORDER BY \(orderBy) """ } @@ -339,9 +310,4 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { guard createIndex < row.count, let ddl = row[createIndex].asText, !ddl.isEmpty else { return nil } return ddl } - - private static func isTruthy(_ value: String) -> Bool { - let lowered = value.lowercased() - return lowered == "t" || lowered == "true" - } } diff --git a/Plugins/PostgreSQLDriverPlugin/CockroachRelationSQL.swift b/Plugins/PostgreSQLDriverPlugin/CockroachRelationSQL.swift new file mode 100644 index 0000000000..ab41816ff1 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/CockroachRelationSQL.swift @@ -0,0 +1,31 @@ +// +// CockroachRelationSQL.swift +// PostgreSQLDriverPlugin +// +// CockroachDB's SHOW statements for one relation. Pure, so it is testable without a server. +// + +import Foundation +import TableProPluginKit + +/// Every statement names the schema of the object asked for, never the session's own schema. +/// CockroachDB resolves a two-part `schema.object` name against the connection's current database, +/// so two parts are all these reads need and the session schema is never consulted. Reading +/// `currentSchema` instead sent an export or a compare of an object outside the connection's schema +/// at a same-named object in that schema, or at no object at all. +/// +/// `schema` is non-optional on purpose: the `?? currentSchema` fallback stays at the call site, +/// where the driver knows its own session, so no caller can reach these builders without one. +public enum CockroachRelationSQL { + public static func showCreateTable(table: String, schema: String) -> String { + "SHOW CREATE TABLE \(PostgreSQLObjectQueries.qualifiedName(schema: schema, name: table))" + } + + public static func showCreateView(view: String, schema: String) -> String { + "SHOW CREATE VIEW \(PostgreSQLObjectQueries.qualifiedName(schema: schema, name: view))" + } + + public static func showIndexes(table: String, schema: String) -> String { + "SHOW INDEXES FROM \(PostgreSQLObjectQueries.qualifiedName(schema: schema, name: table))" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift b/Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift index b0d7f4349c..123ad1202a 100644 --- a/Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift +++ b/Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift @@ -20,20 +20,40 @@ enum ColumnQueryShape { let orderBy: String } - /// Fragments for a column query scoped to one schema. Passing `tableLiteral` - /// restricts the query to a single table; passing `nil` returns every table's - /// columns, prefixes each row with `table_name`, and orders by table. - static func fragments(tableLiteral: String?) -> Fragments { - let includesTableName = tableLiteral == nil + /// Fragments for a column query scoped to one schema. Passing `table` restricts + /// the query to a single table; passing `nil` returns every table's columns, + /// prefixes each row with `table_name`, and orders by table. + /// + /// The name arrives raw and is quoted here through + /// `PostgreSQLObjectQueries.quoteLiteral`, which is the only spelling that reads + /// the same whatever `standard_conforming_strings` is set to. + static func fragments(table: String?) -> Fragments { + let includesTableName = table == nil + let tableLiteral = table.map { PostgreSQLObjectQueries.quoteLiteral($0) } return Fragments( selectPrefix: includesTableName ? "c.table_name,\n" : "", pkSelect: includesTableName ? "kcu.table_name, kcu.column_name" : "kcu.column_name", - pkTableFilter: tableLiteral.map { "\n AND tc.table_name = '\($0)'" } ?? "", + pkTableFilter: tableLiteral.map { "\n AND tc.table_name = \($0)" } ?? "", pkJoin: includesTableName ? "c.table_name = pk.table_name AND c.column_name = pk.column_name" : "c.column_name = pk.column_name", - mainTableFilter: tableLiteral.map { " AND c.table_name = '\($0)'" } ?? "", + mainTableFilter: tableLiteral.map { " AND c.table_name = \($0)" } ?? "", orderBy: includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position" ) } + + static func primaryKeyJoin(schema: String, fragments: Fragments) -> String { + """ + LEFT JOIN ( + SELECT DISTINCT \(fragments.pkSelect) + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = \(PostgreSQLObjectQueries.quoteLiteral(schema))\(fragments.pkTableFilter) + ) pk ON \(fragments.pkJoin) + """ + } } diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index de1560f729..840343d78f 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQCellDecoding.swift b/Plugins/PostgreSQLDriverPlugin/LibPQCellDecoding.swift new file mode 100644 index 0000000000..efe2ae6e44 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQCellDecoding.swift @@ -0,0 +1,29 @@ +// +// LibPQCellDecoding.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +internal enum LibPQCellDecoding { + private static let booleanOid: UInt32 = 16 + private static let byteaOid: UInt32 = 17 + + static func value(from bytes: UnsafeRawBufferPointer, oid: UInt32) -> PluginCellValue { + let text = text(from: bytes) + switch oid { + case byteaOid: + guard let data = LibPQByteaDecoder.decode(text) else { return .text(text) } + return .bytes(data) + case booleanOid: + return .text(text == "t" ? "true" : "false") + default: + return .text(text) + } + } + + static func text(from bytes: UnsafeRawBufferPointer) -> String { + String(decoding: bytes, as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift b/Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift new file mode 100644 index 0000000000..2d655bf50e --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift @@ -0,0 +1,86 @@ +// +// LibPQConnectionLoss.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum LibPQTransactionState: Sendable, Equatable { + case idle + case active + case inTransaction + case inError + case unknown + + var mayHoldTransaction: Bool { + self != .idle + } +} + +enum LibPQServerMessage { + private static let sessionEndingSeverities: Set = ["FATAL", "PANIC"] + private static let sessionEndingClasses: Set = ["08", "57"] + + static func endsSession(severity: String?, sqlState: String?) -> Bool { + if let severity { + return sessionEndingSeverities.contains(severity) + } + guard let sqlState, sqlState.count == 5 else { return false } + return sessionEndingClasses.contains(sqlState.prefix(2)) + } +} + +enum LibPQConnectionLoss: Sendable, Equatable { + case beforeSending(transactionMayBeOpen: Bool) + case afterSending + + init(sent: Bool, recordedState: LibPQTransactionState) { + self = sent ? .afterSending : .beforeSending(transactionMayBeOpen: recordedState.mayHoldTransaction) + } +} + +struct LibPQConnectionLostError: PluginDriverError { + let loss: LibPQConnectionLoss + let underlying: LibPQPluginError + + /// The server's own text stays in front of the explanation. Everything that reads an error + /// reads this string: `DatabaseManager.isAuthenticationFailure` matches "authentication + /// failed" in it, because PostgreSQL sends `28P01` rather than the `28000` its SQLSTATE arm + /// looks for, and `DatabaseWriteRejectionDiagnosis` quotes it back to the user. + var pluginErrorMessage: String { + let parts = [underlying.message, explanation].filter { !$0.isEmpty } + return parts.joined(separator: " ") + } + + private var explanation: String { + switch loss { + case .beforeSending(transactionMayBeOpen: false): + return String( + localized: "The connection to the server was closed before the statement was sent. It was not run." + ) + case .beforeSending(transactionMayBeOpen: true): + return String( + localized: """ + The connection to the server was closed before the statement was sent. It was not run, and \ + any open transaction was rolled back. + """ + ) + case .afterSending: + return String( + localized: """ + Lost the connection to the server while the statement was running. It may or may not have \ + completed. If a transaction was open, it was rolled back unless this statement committed it. + """ + ) + } + } + + var pluginSqlState: String? { + underlying.sqlState + } + + var pluginErrorDetail: String? { + underlying.detail + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift b/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift new file mode 100644 index 0000000000..a06fc2514b --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift @@ -0,0 +1,66 @@ +// +// LibPQConnectionString.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +internal enum LibPQConnectionString { + static let clientEncoding = "UTF8" + + private static let clientEncodingNames: Set = ["UTF8", "UNICODE"] + + static func build( + host: String, + port: Int, + user: String, + password: String?, + database: String, + sslConfig: SSLConfiguration, + options: String? + ) -> String { + var parameters: [(String, String)] = [ + ("host", host), + ("port", String(port)), + ("dbname", database) + ] + + if !user.isEmpty { + parameters.append(("user", user)) + } + if let password, !password.isEmpty { + parameters.append(("password", password)) + } + + parameters.append(("sslmode", LibPQSSLMapping.sslmode(for: sslConfig.mode))) + if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { + parameters.append(("sslrootcert", sslConfig.caCertificatePath)) + } + if !sslConfig.clientCertificatePath.isEmpty { + parameters.append(("sslcert", sslConfig.clientCertificatePath)) + } + if !sslConfig.clientKeyPath.isEmpty { + parameters.append(("sslkey", sslConfig.clientKeyPath)) + } + + parameters.append(("client_encoding", clientEncoding)) + if let options, !options.isEmpty { + parameters.append(("options", options)) + } + + return parameters + .map { "\($0.0)='\(escape($0.1))'" } + .joined(separator: " ") + } + + static func isClientEncoding(reportedByServer reported: String?) -> Bool { + guard let reported else { return false } + return clientEncodingNames.contains(reported.uppercased()) + } + + static func escape(_ value: String) -> String { + value.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQCopyDirection+UnsupportedMessage.swift b/Plugins/PostgreSQLDriverPlugin/LibPQCopyDirection+UnsupportedMessage.swift new file mode 100644 index 0000000000..5feed69b60 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQCopyDirection+UnsupportedMessage.swift @@ -0,0 +1,19 @@ +// +// LibPQCopyDirection+UnsupportedMessage.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +nonisolated extension LibPQCopyDirection { + var unsupportedMessage: String { + switch self { + case .copyIn: + return String(localized: "The query editor cannot send data to COPY FROM STDIN, so no rows were sent. Use Import to load a file into a table.") + case .copyOut: + return String(localized: "The query editor cannot receive the output of COPY TO STDOUT, so it was discarded. Run a SELECT, or use Export to save the rows to a file.") + case .copyBoth: + return String(localized: "The query editor cannot run a replication COPY.") + } + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQCopyState.swift b/Plugins/PostgreSQLDriverPlugin/LibPQCopyState.swift new file mode 100644 index 0000000000..eb25f8e47f --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQCopyState.swift @@ -0,0 +1,76 @@ +// +// LibPQCopyState.swift +// PostgreSQLDriverPlugin +// + +import CLibPQ +import Foundation + +nonisolated enum LibPQCopyState { + static let unsentInputReason = "no COPY data was sent" + + static func copy(of result: OpaquePointer) -> LibPQCopy? { + guard let direction = direction(of: PQresultStatus(result)) else { return nil } + return LibPQCopy(direction: direction, format: PQbinaryTuples(result) == 1 ? .binary : .textual) + } + + static func direction(of status: ExecStatusType) -> LibPQCopyDirection? { + switch status { + case PGRES_COPY_IN: return .copyIn + case PGRES_COPY_OUT: return .copyOut + case PGRES_COPY_BOTH: return .copyBoth + default: return nil + } + } + + static func finishPendingResults(_ conn: OpaquePointer, cancellingOutput: Bool) -> LibPQDrainOutcome { + LibPQPendingResultDrain.drain( + nextResult: { + guard let result = PQgetResult(conn) else { return nil } + defer { PQclear(result) } + return copy(of: result).map(LibPQPendingResult.copy) ?? .completed + }, + endCopy: { end($0, conn: conn, cancellingOutput: cancellingOutput) } + ) + } + + /// A textual or CSV `COPY FROM STDIN` ends with CopyDone, which completes the statement with + /// zero rows and leaves an open transaction in `INTRANS`. CopyFail aborts the transaction, so + /// it is kept for binary input alone, where CopyDone fails on the missing file signature. + /// Measured on 9.1.24 and 17.11. + static func end(_ copy: LibPQCopy, conn: OpaquePointer, cancellingOutput: Bool) { + switch copy.direction { + case .copyIn: + endInput(copy, conn: conn) + case .copyOut: + discardOutput(conn, cancelling: cancellingOutput) + case .copyBoth: + endInput(copy, conn: conn) + discardOutput(conn, cancelling: cancellingOutput) + } + } + + private static func endInput(_ copy: LibPQCopy, conn: OpaquePointer) { + guard copy.format == .binary else { + _ = PQputCopyEnd(conn, nil) + return + } + _ = unsentInputReason.withCString { PQputCopyEnd(conn, $0) } + } + + private static func discardOutput(_ conn: OpaquePointer, cancelling: Bool) { + if cancelling { cancelStatement(conn) } + var buffer: UnsafeMutablePointer? + while PQgetCopyData(conn, &buffer, 0) > 0 { + PQfreemem(buffer) + buffer = nil + } + } + + private static func cancelStatement(_ conn: OpaquePointer) { + guard let cancelObject = PQgetCancel(conn) else { return } + defer { PQfreeCancel(cancelObject) } + var errbuf = [CChar](repeating: 0, count: 256) + PQcancel(cancelObject, &errbuf, Int32(errbuf.count)) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index 5a606609c5..7927b87d53 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -13,7 +13,13 @@ final class LibPQDriverCore: @unchecked Sendable { private let config: DriverConnectionConfig private let schemaFallbackQueries: [String] private let singleConnectionMode: Bool - private var libpqConnection: LibPQPluginConnection? + private let connectionLock = NSLock() + private var _libpqConnection: LibPQPluginConnection? + private var _lostConnection = false + + private var libpqConnection: LibPQPluginConnection? { + connectionLock.withLock { _libpqConnection } + } var currentSchema: String = "public" private var selectedSchema: String? @@ -26,7 +32,20 @@ final class LibPQDriverCore: @unchecked Sendable { var stageReporter: ConnectionStageReporter? var serverVersion: String? { libpqConnection?.serverVersion() } + /// Latched, because `disconnect()` drops the connection object that knew it and the app asks + /// this question of the driver it is still holding: the pool closes a lost entry, and the + /// before-use check pings one, both after something disconnected it. + var hasLostConnection: Bool { + connectionLock.withLock { + if _libpqConnection?.hasLostConnection == true { + _lostConnection = true + } + return _lostConnection + } + } var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 } + var standardConformingStrings: Bool { libpqConnection?.standardConformingStrings ?? true } + var isInsideTransactionBlock: Bool { libpqConnection?.isInsideTransactionBlock ?? false } init( config: DriverConnectionConfig, @@ -53,7 +72,10 @@ final class LibPQDriverCore: @unchecked Sendable { ) try await pqConn.connect(reportingStage: stageReporter ?? { _ in }) - libpqConnection = pqConn + connectionLock.withLock { + _libpqConnection = pqConn + _lostConnection = false + } switch await probeSchema(pqConn, query: PostgreSQLSchemaQueries.currentSchema) { case .schema(let schema): @@ -96,18 +118,16 @@ final class LibPQDriverCore: @unchecked Sendable { } func disconnect() { - libpqConnection?.disconnect() - libpqConnection = nil + let pqConn = connectionLock.withLock { () -> LibPQPluginConnection? in + defer { _libpqConnection = nil } + if _libpqConnection?.hasLostConnection == true { + _lostConnection = true + } + return _libpqConnection + } + pqConn?.disconnect() } - /// Non-reconnecting on purpose, which is what makes the answer mean anything. - /// - /// `execute` recovers a dropped connection privately, and that recovery restores none of the - /// session state the app put there: the startup commands, the query timeout, the database and - /// the schema all belong to `DatabaseManager.reconnectDriver`. A ping that healed itself that - /// way would report success into a server session reset behind the user's back, and the next - /// statement would run without the role, search path or time zone their startup SQL set. - /// Failing instead routes recovery through the manager, which restores all of it. func ping() async throws { guard let pqConn = libpqConnection else { throw LibPQPluginError.notConnected @@ -118,13 +138,21 @@ final class LibPQDriverCore: @unchecked Sendable { // MARK: - Query Execution func execute(query: String) async throws -> PluginQueryResult { - try await executeWithReconnect(query: query, isRetry: false) + let pqConn = try connection() + let startTime = Date() + let result = try await pqConn.executeQuery(query) + return PluginQueryResult( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + rows: result.rows, + rowsAffected: result.affectedRows, + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated + ) } func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { - guard let pqConn = libpqConnection else { - throw LibPQPluginError.notConnected - } + let pqConn = try connection() let startTime = Date() let result = try await pqConn.executeParameterizedQuery(query, parameters: parameters) return PluginQueryResult( @@ -138,39 +166,20 @@ final class LibPQDriverCore: @unchecked Sendable { } func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult? { - try await boundedQueryWithReconnect(query: query, rowCap: rowCap, isRetry: false) - } - - /// A bounded read only ever runs a statement the host classified as a read, so retrying it after - /// a dropped connection is safe, the same way the buffered path retries. - private func boundedQueryWithReconnect( - query: String, - rowCap: Int, - isRetry: Bool - ) async throws -> PluginQueryResult { - guard let pqConn = libpqConnection else { - throw LibPQPluginError.notConnected - } - + let pqConn = try connection() let startTime = Date() - - do { - let result = try await pqConn.boundedQuery(query, rowCap: rowCap) - return PluginQueryResult( - columns: result.columns, - columnTypeNames: result.columnTypeNames, - rows: result.rows, - rowsAffected: result.affectedRows, - timing: PluginQueryTiming( - total: Date().timeIntervalSince(startTime), - firstRow: result.firstRowTime - ), - isTruncated: result.isTruncated - ) - } catch let error as NSError where !isRetry && Self.isConnectionLostError(error) { - try await reconnect() - return try await boundedQueryWithReconnect(query: query, rowCap: rowCap, isRetry: true) - } + let result = try await pqConn.boundedQuery(query, rowCap: rowCap) + return PluginQueryResult( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + rows: result.rows, + rowsAffected: result.affectedRows, + timing: PluginQueryTiming( + total: Date().timeIntervalSince(startTime), + firstRow: result.firstRowTime + ), + isTruncated: result.isTruncated + ) } func streamRows(query: String) -> AsyncThrowingStream { @@ -184,7 +193,7 @@ final class LibPQDriverCore: @unchecked Sendable { libpqConnection?.cancelCurrentQuery() } - func setPostgisOidMap(_ map: [UInt32: String]) { + func setPostgisOidMap(_ map: [UInt32: PostGISType]) { libpqConnection?.setPostgisOidMap(map) } @@ -194,47 +203,14 @@ final class LibPQDriverCore: @unchecked Sendable { func applyQueryTimeout(_ seconds: Int) async throws { let ms = seconds * 1_000 - _ = try await execute(query: "SET statement_timeout = '\(ms)'") + _ = try await execute(query: "SET statement_timeout = \(ms)") } - // MARK: - Reconnect - - private func executeWithReconnect(query: String, isRetry: Bool) async throws -> PluginQueryResult { + private func connection() throws -> LibPQPluginConnection { guard let pqConn = libpqConnection else { throw LibPQPluginError.notConnected } - - let startTime = Date() - - do { - let result = try await pqConn.executeQuery(query) - return PluginQueryResult( - columns: result.columns, - columnTypeNames: result.columnTypeNames, - rows: result.rows, - rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime), - isTruncated: result.isTruncated - ) - } catch let error as NSError where !isRetry && Self.isConnectionLostError(error) { - try await reconnect() - return try await executeWithReconnect(query: query, isRetry: true) - } - } - - private func reconnect() async throws { - libpqConnection?.disconnect() - libpqConnection = nil - try await connect() - } - - private static func isConnectionLostError(_ error: NSError) -> Bool { - let errorMessage = error.localizedDescription.lowercased() - return errorMessage.contains("connection") && - (errorMessage.contains("lost") || - errorMessage.contains("closed") || - errorMessage.contains("no connection") || - errorMessage.contains("could not send")) + return pqConn } } @@ -331,9 +307,15 @@ extension LibPQBackedDriver { } var serverVersion: String? { core.serverVersion } + var hasLostConnection: Bool { core.hasLostConnection } var parameterStyle: ParameterStyle { .dollar } - func escapeLiteral(_ str: String) -> String { - escapeStringLiteral(str) + /// The PluginKit requirement, whose contract is inner text: the app and the export plugins wrap + /// the result in their own quotes. Nothing in this plugin may build catalog SQL with it, because + /// its correctness rests on `standard_conforming_strings` still being what the last connection + /// message reported. `PostgreSQLObjectQueries.quoteLiteral` needs no such agreement, so every + /// statement this plugin builds goes through that instead. + func escapeStringLiteral(_ value: String) -> String { + LibPQStringConformance.escape(value, standardConformingStrings: core.standardConformingStrings) } } diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPendingResultDrain.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPendingResultDrain.swift new file mode 100644 index 0000000000..204df32393 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPendingResultDrain.swift @@ -0,0 +1,60 @@ +// +// LibPQPendingResultDrain.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +nonisolated enum LibPQCopyDirection: Sendable, Equatable { + case copyIn + case copyOut + case copyBoth +} + +nonisolated enum LibPQCopyFormat: Sendable, Equatable { + case textual + case binary +} + +nonisolated struct LibPQCopy: Sendable, Equatable { + let direction: LibPQCopyDirection + let format: LibPQCopyFormat +} + +nonisolated enum LibPQPendingResult: Sendable, Equatable { + case copy(LibPQCopy) + case completed +} + +nonisolated struct LibPQDrainOutcome: Sendable, Equatable { + let endedCopies: [LibPQCopy] + let stuckInCopy: LibPQCopy? + + static let idle = LibPQDrainOutcome(endedCopies: [], stuckInCopy: nil) + + var abandonedCopy: LibPQCopy? { endedCopies.first ?? stuckInCopy } + var leavesConnectionUnusable: Bool { stuckInCopy != nil } +} + +nonisolated enum LibPQPendingResultDrain { + static func drain( + nextResult: () -> LibPQPendingResult?, + endCopy: (LibPQCopy) -> Void + ) -> LibPQDrainOutcome { + var endedCopies: [LibPQCopy] = [] + var copyAwaitingCompletion: LibPQCopy? + while let pending = nextResult() { + guard case .copy(let copy) = pending else { + copyAwaitingCompletion = nil + continue + } + if let unfinished = copyAwaitingCompletion { + return LibPQDrainOutcome(endedCopies: endedCopies, stuckInCopy: unfinished) + } + copyAwaitingCompletion = copy + endedCopies.append(copy) + endCopy(copy) + } + return LibPQDrainOutcome(endedCopies: endedCopies, stuckInCopy: nil) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index c979af827a..7e334fdbbd 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -14,21 +14,6 @@ import TableProPluginKit private let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "LibPQPluginConnection") -// MARK: - Error Types - -struct LibPQPluginError: Error { - let message: String - let sqlState: String? - let detail: String? - - static let notConnected = LibPQPluginError( - message: String(localized: "Not connected to database"), sqlState: nil, detail: nil) - static let connectionFailed = LibPQPluginError( - message: String(localized: "Failed to establish connection"), sqlState: nil, detail: nil) - static let connectionTimedOut = LibPQPluginError( - message: String(localized: "Timed out while connecting to the server"), sqlState: nil, detail: nil) -} - // MARK: - Query Result struct LibPQPluginQueryResult { @@ -132,8 +117,12 @@ final class LibPQPluginConnection: @unchecked Sendable { private var _cachedServerVersion: String? private var _cachedServerVersionNumber: Int32 = 0 private var _isConnectCancelled: Bool = false - private var _postgisOidMap: [UInt32: String] = [:] + private var _postgisOidMap: [UInt32: PostGISType] = [:] private var _catalogTypeNames: [UInt32: String] = [:] + private var _lastTransactionState: LibPQTransactionState = .idle + private var _hasLostConnection = false + private var serverMessages: Unmanaged? + private var _standardConformingStrings = true var isConnected: Bool { stateLock.lock() @@ -141,6 +130,10 @@ final class LibPQPluginConnection: @unchecked Sendable { return _isConnected } + var standardConformingStrings: Bool { + stateLock.withLock { _standardConformingStrings } + } + private var isShuttingDown: Bool { get { stateLock.lock() @@ -176,11 +169,14 @@ final class LibPQPluginConnection: @unchecked Sendable { deinit { let handle = conn + let sink = serverMessages let cleanupQueue = queue conn = nil + serverMessages = nil if let handle = handle { cleanupQueue.async { PQfinish(handle) + sink?.release() } } } @@ -215,7 +211,7 @@ final class LibPQPluginConnection: @unchecked Sendable { } private func performConnect(reportingStage report: @escaping ConnectionStageReporter) throws { - guard let connection = buildConnectionString().withCString({ PQconnectStart($0) }) else { + guard let connection = connectionString.withCString({ PQconnectStart($0) }) else { throw LibPQPluginError.connectionFailed } @@ -230,9 +226,13 @@ final class LibPQPluginConnection: @unchecked Sendable { try pollUntilConnected(connection, reportingStage: report) configureEstablishedConnection(connection) + let sink = LibPQServerMessageSink.install(on: connection) stateLock.lock() conn = connection + serverMessages = sink + _lastTransactionState = .idle + _hasLostConnection = false _isConnected = true stateLock.unlock() adopted = true @@ -314,10 +314,13 @@ final class LibPQPluginConnection: @unchecked Sendable { } private func configureEstablishedConnection(_ connection: OpaquePointer) { - "SET client_encoding TO 'UTF8'".withCString { cStr in - let result = PQexec(connection, cStr) - PQclear(result) - } + logUnexpectedClientEncoding(of: connection) + runSessionSetupStatement(LibPQStringConformance.enableStatement, on: connection) + storeStandardConformingStrings( + reportedStandardConformingStrings(on: connection) + ?? queriedStandardConformingStrings(on: connection) + ?? true + ) let version = PQserverVersion(connection) guard version > 0 else { return } @@ -334,39 +337,66 @@ final class LibPQPluginConnection: @unchecked Sendable { } } - private func buildConnectionString() -> String { - func escapeConnParam(_ value: String) -> String { - value.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - } + private func logUnexpectedClientEncoding(of connection: OpaquePointer) { + let reported = PQparameterStatus(connection, "client_encoding").map { String(cString: $0) } + guard !LibPQConnectionString.isClientEncoding(reportedByServer: reported) else { return } + logger.warning( + "Server reports client_encoding \(reported ?? "none", privacy: .public) instead of UTF8" + ) + } - var connStr = "host='\(escapeConnParam(host))' port='\(port)' dbname='\(escapeConnParam(database))'" + private func runSessionSetupStatement(_ statement: String, on connection: OpaquePointer) { + let result = statement.withCString { PQexec(connection, $0) } + defer { PQclear(result) } + guard PQresultStatus(result) != PGRES_COMMAND_OK else { return } + let message = result.flatMap { PQresultErrorMessage($0) }.map { String(cString: $0) } ?? "" + logger.warning( + "Session setup statement failed: \(statement, privacy: .public) \(message, privacy: .public)" + ) + } - if !user.isEmpty { - connStr += " user='\(escapeConnParam(user))'" + private func reportedStandardConformingStrings(on connection: OpaquePointer) -> Bool? { + guard let value = PQparameterStatus(connection, LibPQStringConformance.parameterName) else { + return nil } + return LibPQStringConformance.isOn(String(cString: value)) + } - if let password, !password.isEmpty { - connStr += " password='\(escapeConnParam(password))'" + private func queriedStandardConformingStrings(on connection: OpaquePointer) -> Bool? { + let result = LibPQStringConformance.showQuery.withCString { PQexec(connection, $0) } + defer { PQclear(result) } + guard PQresultStatus(result) == PGRES_TUPLES_OK, + PQntuples(result) > 0, + let value = PQgetvalue(result, 0, 0) else { + return nil } + return LibPQStringConformance.isOn(String(cString: value)) + } - connStr += " sslmode='\(LibPQSSLMapping.sslmode(for: sslConfig.mode))'" - - if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { - connStr += " sslrootcert='\(escapeConnParam(sslConfig.caCertificatePath))'" - } - if !sslConfig.clientCertificatePath.isEmpty { - connStr += " sslcert='\(escapeConnParam(sslConfig.clientCertificatePath))'" - } - if !sslConfig.clientKeyPath.isEmpty { - connStr += " sslkey='\(escapeConnParam(sslConfig.clientKeyPath))'" - } + private func refreshStandardConformingStrings(from connection: OpaquePointer) { + guard let reported = reportedStandardConformingStrings(on: connection) else { return } + storeStandardConformingStrings(reported) + } - if let options, !options.isEmpty { - connStr += " options='\(escapeConnParam(options))'" + private func storeStandardConformingStrings(_ value: Bool) { + let changed = stateLock.withLock { + defer { _standardConformingStrings = value } + return _standardConformingStrings != value } + guard changed, !value else { return } + logger.warning("standard_conforming_strings is off; string literals escape backslashes") + } - return connStr + private var connectionString: String { + LibPQConnectionString.build( + host: host, + port: port, + user: user, + password: password, + database: database, + sslConfig: sslConfig, + options: options + ) } func disconnect() { @@ -376,7 +406,9 @@ final class LibPQPluginConnection: @unchecked Sendable { _isConnected = false _isConnectCancelled = true let handle = conn + let sink = serverMessages conn = nil + serverMessages = nil stateLock.unlock() _cachedServerVersion = nil @@ -385,19 +417,20 @@ final class LibPQPluginConnection: @unchecked Sendable { if let handle { queue.async { PQfinish(handle) + sink?.release() } } } // MARK: - PostGIS OID Map - func setPostgisOidMap(_ map: [UInt32: String]) { + func setPostgisOidMap(_ map: [UInt32: PostGISType]) { stateLock.lock() _postgisOidMap = map stateLock.unlock() } - private var postgisOidMap: [UInt32: String] { + private var postgisOidMap: [UInt32: PostGISType] { stateLock.lock() defer { stateLock.unlock() } return _postgisOidMap @@ -547,6 +580,22 @@ final class LibPQPluginConnection: @unchecked Sendable { _cachedServerVersionNumber } + /// Whether the session is inside a transaction block, including one a failed statement has + /// aborted. A statement sent now joins that block rather than running on its own. + /// + /// Read on the connection's own queue, like every other libpq call here: one `PGconn` may not + /// be used from two threads at once, and the lock alone guards the pointer rather than the call. + var isInsideTransactionBlock: Bool { + stateLock.lock() + let conn = self.conn + stateLock.unlock() + guard let conn else { return false } + return queue.sync { + let status = PQtransactionStatus(conn) + return status == PQTRANS_INTRANS || status == PQTRANS_INERROR + } + } + func currentDatabase() -> String { database } @@ -564,6 +613,10 @@ final class LibPQPluginConnection: @unchecked Sendable { let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } + defer { refreshStandardConformingStrings(from: conn) } + + let cancelsOutput = cancelsAbandonedOutput(conn) + if let ended = sessionEndedBeforeSending(conn) { throw ended } let localQuery = String(query) let result: OpaquePointer? = localQuery.withCString { queryPtr in @@ -571,8 +624,9 @@ final class LibPQPluginConnection: @unchecked Sendable { } guard let result = result else { - throw getError(from: conn) + throw lostConnection(getError(from: conn), on: conn, sent: true) } + recordTransactionState(of: conn) let status = PQresultStatus(result) @@ -597,10 +651,14 @@ final class LibPQPluginConnection: @unchecked Sendable { return try fetchResults(from: result, conn: conn, generation: generation) default: + if let copy = LibPQCopyState.copy(of: result) { + PQclear(result) + throw abandonedCopyError(copy, conn: conn, cancellingOutput: cancelsOutput, generation: generation) + } let error = getResultError(from: result) PQclear(result) if cancellationGate.isCancelled(generation) { throw CancellationError() } - throw error + throw lostConnection(error, on: conn, sent: true) } } @@ -622,27 +680,31 @@ final class LibPQPluginConnection: @unchecked Sendable { let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } + defer { refreshStandardConformingStrings(from: conn) } /// Started before the drain, so a result the previous statement abandoned is charged to the /// time before the first row rather than appearing as this query's row transfer. let sentAt = Date() - while let stale = PQgetResult(conn) { PQclear(stale) } /// Cancelling a statement inside a transaction block puts the transaction into the aborted /// state, and every later command fails until ROLLBACK. Reading the tail of one result costs /// less than throwing away the transaction the user opened, so the cancel is withheld here /// and the connection is drained instead. - let insideTransaction = PQtransactionStatus(conn) == PQTRANS_INTRANS - let suppressCancel = suppressServerSideCancel || insideTransaction + let cancelsOutput = cancelsAbandonedOutput(conn) + let suppressCancel = !cancelsOutput + _ = finishPendingResults(conn, cancellingOutput: cancelsOutput) + if let ended = sessionEndedBeforeSending(conn) { throw ended } let localQuery = String(query) let sendOk = localQuery.withCString { queryPtr in PQsendQuery(conn, queryPtr) } - guard sendOk != 0 else { throw getError(from: conn) } + guard sendOk != 0 else { + throw lostConnection(getError(from: conn), on: conn, sent: false) + } guard PQsetSingleRowMode(conn) != 0 else { - Self.cancelAndDrain(conn, suppressCancel: suppressCancel) + _ = cancelAndDrain(conn, suppressCancel: suppressCancel) throw LibPQPluginError(message: "Failed to enter single-row mode", sqlState: nil, detail: nil) } @@ -654,6 +716,7 @@ final class LibPQPluginConnection: @unchecked Sendable { var truncated = false var pendingError: Error? var firstRowTime: TimeInterval? + var failedStatement: LibPQPluginError? while let result = PQgetResult(conn) { let status = PQresultStatus(result) @@ -677,7 +740,7 @@ final class LibPQPluginConnection: @unchecked Sendable { rows.append(row) if cancellationGate.isCancelled(generation) { - Self.cancelAndDrain(conn, suppressCancel: suppressCancel) + _ = cancelAndDrain(conn, suppressCancel: suppressCancel) throw CancellationError() } if rows.count > rowCap { @@ -700,22 +763,32 @@ final class LibPQPluginConnection: @unchecked Sendable { continue } - pendingError = getResultError(from: result) + if let copy = LibPQCopyState.copy(of: result) { + pendingError = Self.unsupportedCopyError(copy) + PQclear(result) + break + } + + failedStatement = getResultError(from: result) PQclear(result) break } - if truncated { - Self.cancelAndDrain(conn, suppressCancel: suppressCancel) - } else { - while let trailing = PQgetResult(conn) { PQclear(trailing) } - } + let outcome = truncated + ? cancelAndDrain(conn, suppressCancel: suppressCancel) + : finishPendingResults(conn, cancellingOutput: cancelsOutput) + recordTransactionState(of: conn) + if let failedStatement { + if cancellationGate.isCancelled(generation) { throw CancellationError() } + throw lostConnection(failedStatement, on: conn, sent: true) + } if let pendingError { if cancellationGate.isCancelled(generation) { throw CancellationError() } throw pendingError } if cancellationGate.isCancelled(generation) { throw CancellationError() } + if let abandoned = abandonedCopyError(outcome, generation: generation) { throw abandoned } if truncated { rows.removeLast() } @@ -745,7 +818,9 @@ final class LibPQPluginConnection: @unchecked Sendable { let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } + defer { refreshStandardConformingStrings(from: conn) } + let cancelsOutput = cancelsAbandonedOutput(conn) var paramValues: [UnsafePointer?] = [] var paramLengths: [Int32] = [] var paramFormats: [Int32] = [] @@ -790,6 +865,8 @@ final class LibPQPluginConnection: @unchecked Sendable { } } + if let ended = sessionEndedBeforeSending(conn) { throw ended } + let localQuery = String(query) let result: OpaquePointer? = localQuery.withCString { queryPtr in paramLengths.withUnsafeBufferPointer { lengthsBuf in @@ -809,8 +886,9 @@ final class LibPQPluginConnection: @unchecked Sendable { } guard let result = result else { - throw getError(from: conn) + throw lostConnection(getError(from: conn), on: conn, sent: true) } + recordTransactionState(of: conn) let status = PQresultStatus(result) @@ -835,16 +913,64 @@ final class LibPQPluginConnection: @unchecked Sendable { return try fetchResults(from: result, conn: conn, generation: generation) default: + if let copy = LibPQCopyState.copy(of: result) { + PQclear(result) + throw abandonedCopyError(copy, conn: conn, cancellingOutput: cancelsOutput, generation: generation) + } let error = getResultError(from: result) PQclear(result) if cancellationGate.isCancelled(generation) { throw CancellationError() } - throw error + throw lostConnection(error, on: conn, sent: true) } } + // MARK: - Pending Results + + /// A statement the user did not get to see is worse than a slow one, so a COPY ended by any + /// drain is reported rather than swallowed: `INSERT INTO t VALUES (1); COPY t FROM STDIN` used + /// to come back as "INSERT 0 1" with the COPY discarded. + private func abandonedCopyError(_ outcome: LibPQDrainOutcome, generation: Int) -> Error? { + guard let copy = outcome.abandonedCopy else { return nil } + if cancellationGate.isCancelled(generation) { return CancellationError() } + return Self.unsupportedCopyError(copy) + } + + private func abandonedCopyError( + _ copy: LibPQCopy, + conn: OpaquePointer, + cancellingOutput: Bool, + generation: Int + ) -> Error { + _ = finishPendingResults(conn, cancellingOutput: cancellingOutput) + if cancellationGate.isCancelled(generation) { return CancellationError() } + return Self.unsupportedCopyError(copy) + } + + private static func unsupportedCopyError(_ copy: LibPQCopy) -> LibPQPluginError { + LibPQPluginError(message: copy.direction.unsupportedMessage, sqlState: nil, detail: nil) + } + + /// Reading `COPY TO STDOUT` to its end defeats the row cap the bounded read exists for, so the + /// statement is cancelled first wherever a cancel is safe. Inside a transaction block it is not, + /// because a cancel aborts the transaction the user opened. + private func cancelsAbandonedOutput(_ conn: OpaquePointer) -> Bool { + !suppressServerSideCancel && PQtransactionStatus(conn) != PQTRANS_INTRANS + } + + private func finishPendingResults(_ conn: OpaquePointer, cancellingOutput: Bool) -> LibPQDrainOutcome { + let outcome = LibPQCopyState.finishPendingResults(conn, cancellingOutput: cancellingOutput) + guard let stuck = outcome.stuckInCopy else { return outcome } + logger.fault( + "libpq stayed in \(String(describing: stuck.direction), privacy: .public); dropping the connection" + ) + stateLock.withLock { _hasLostConnection = true } + disconnect() + return outcome + } + // MARK: - Streaming Query - private static func cancelAndDrain(_ conn: OpaquePointer, suppressCancel: Bool) { + private func cancelAndDrain(_ conn: OpaquePointer, suppressCancel: Bool) -> LibPQDrainOutcome { if !suppressCancel { let cancelObj = PQgetCancel(conn) if let cancelObj { @@ -853,7 +979,7 @@ final class LibPQPluginConnection: @unchecked Sendable { PQfreeCancel(cancelObj) } } - while let res = PQgetResult(conn) { PQclear(res) } + return finishPendingResults(conn, cancellingOutput: false) } /// The abort is polled by the producer rather than acted on from `onTermination`, because both @@ -875,6 +1001,7 @@ final class LibPQPluginConnection: @unchecked Sendable { let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } + defer { refreshStandardConformingStrings(from: conn) } /// The consumer can go away before this block is scheduled, in which case the /// query is never sent at all. @@ -883,24 +1010,27 @@ final class LibPQPluginConnection: @unchecked Sendable { return } - while let res = PQgetResult(conn) { PQclear(res) } - /// Read before the query goes out: once it is in flight the status is /// PQTRANS_ACTIVE, and the transaction this guard exists for is invisible. - let suppressCancel = suppressServerSideCancel - || PQtransactionStatus(conn) == PQTRANS_INTRANS + let cancelsOutput = cancelsAbandonedOutput(conn) + let suppressCancel = !cancelsOutput + _ = finishPendingResults(conn, cancellingOutput: cancelsOutput) + if let ended = sessionEndedBeforeSending(conn) { + continuation.finish(throwing: ended) + return + } let sendOk = queryToRun.withCString { queryPtr in PQsendQuery(conn, queryPtr) } if sendOk == 0 { - continuation.finish(throwing: getError(from: conn)) + continuation.finish(throwing: lostConnection(getError(from: conn), on: conn, sent: false)) return } if PQsetSingleRowMode(conn) == 0 { - Self.cancelAndDrain(conn, suppressCancel: suppressCancel) + _ = cancelAndDrain(conn, suppressCancel: suppressCancel) continuation.finish(throwing: LibPQPluginError( message: "Failed to enter single-row mode", sqlState: nil, detail: nil)) return @@ -968,7 +1098,7 @@ final class LibPQPluginConnection: @unchecked Sendable { if !batch.isEmpty { continuation.yield(.rows(batch)) } - Self.cancelAndDrain(conn, suppressCancel: suppressCancel) + _ = cancelAndDrain(conn, suppressCancel: suppressCancel) continuation.finish(throwing: CancellationError()) return } @@ -979,15 +1109,21 @@ final class LibPQPluginConnection: @unchecked Sendable { lastCommandTag = getCommandTag(from: result) PQclear(result) break + } else if let copy = LibPQCopyState.copy(of: result) { + PQclear(result) + continuation.finish(throwing: abandonedCopyError( + copy, conn: conn, cancellingOutput: cancelsOutput, generation: generation)) + return } else { let error = getResultError(from: result) PQclear(result) - while let res = PQgetResult(conn) { PQclear(res) } + _ = finishPendingResults(conn, cancellingOutput: cancelsOutput) + recordTransactionState(of: conn) if cancellationGate.isCancelled(generation) { continuation.finish(throwing: CancellationError()) return } - continuation.finish(throwing: error) + continuation.finish(throwing: lostConnection(error, on: conn, sent: true)) return } } @@ -996,7 +1132,8 @@ final class LibPQPluginConnection: @unchecked Sendable { continuation.yield(.rows(batch)) } - while let res = PQgetResult(conn) { PQclear(res) } + let outcome = finishPendingResults(conn, cancellingOutput: cancelsOutput) + recordTransactionState(of: conn) /// The header went out with the first row, so this stream keeps what it said; /// the lookup is for the results that follow. let missing = unresolvedOids(in: columnOids) @@ -1004,6 +1141,10 @@ final class LibPQPluginConnection: @unchecked Sendable { learnTypeNames(for: missing, conn: conn) } noteCommandTag(lastCommandTag, conn: conn) + if let abandoned = abandonedCopyError(outcome, generation: generation) { + continuation.finish(throwing: abandoned) + return + } continuation.finish() } } @@ -1032,9 +1173,9 @@ final class LibPQPluginConnection: @unchecked Sendable { let oidMap = postgisOidMap guard !oidMap.isEmpty else { return result } - let spatialColumns = result.columnOids.enumerated().compactMap { index, oid -> (index: Int, typeName: String)? in - guard let typeName = oidMap[oid] else { return nil } - return (index, typeName) + let spatialColumns = result.columnOids.enumerated().compactMap { index, oid -> (index: Int, type: PostGISType)? in + guard let type = oidMap[oid] else { return nil } + return (index, type) } guard !spatialColumns.isEmpty else { return result } @@ -1071,34 +1212,41 @@ final class LibPQPluginConnection: @unchecked Sendable { private func renderSpatialColumns( _ result: LibPQPluginQueryResult, - spatialColumns: [(index: Int, typeName: String)] + spatialColumns: [(index: Int, type: PostGISType)] ) -> LibPQPluginQueryResult { var rows = result.rows var columnTypeNames = result.columnTypeNames + var pending: [(index: Int, query: String, hexValues: [String?])] = [] for column in spatialColumns { if column.index < columnTypeNames.count { - columnTypeNames[column.index] = column.typeName + columnTypeNames[column.index] = column.type.name } - guard let query = PostGISSpatialRewrite.conversionQuery(forTypeName: column.typeName) else { continue } + guard let query = PostGISSpatialRewrite.conversionQuery(for: column.type) else { continue } let hexValues: [String?] = rows.map { row in guard column.index < row.count, case let .text(hex) = row[column.index] else { return nil } return hex } guard hexValues.contains(where: { $0 != nil }) else { continue } + pending.append((index: column.index, query: query, hexValues: hexValues)) + } - guard let converted = convertSpatialValues(hexValues, query: query), - converted.count == hexValues.count else { - logger.warning("PostGIS value conversion failed for column \(column.index); keeping raw hex") - continue - } + if !pending.isEmpty, let scope = SpatialRenderScope(connection: self) { + for column in pending { + guard let converted = scope.convert(column.hexValues, query: column.query), + converted.count == column.hexValues.count else { + logger.warning("PostGIS value conversion failed for column \(column.index); keeping raw hex") + continue + } - for (rowIndex, value) in converted.enumerated() - where hexValues[rowIndex] != nil && column.index < rows[rowIndex].count { - rows[rowIndex][column.index] = value + for (rowIndex, value) in converted.enumerated() + where column.hexValues[rowIndex] != nil && column.index < rows[rowIndex].count { + rows[rowIndex][column.index] = value + } } + scope.finish() } return LibPQPluginQueryResult( @@ -1113,27 +1261,62 @@ final class LibPQPluginConnection: @unchecked Sendable { ) } - private func convertSpatialValues(_ hexValues: [String?], query: String) -> [PluginCellValue]? { - stateLock.lock() - let conn = self.conn - stateLock.unlock() - guard let conn else { return nil } + /// One savepoint for a whole rendering pass rather than one per column: the conversion is a + /// side query on the user's own session, and an open transaction must survive a PostGIS error + /// (an older server has no ST_AsEWKT at all) without costing three round trips per column. + private final class SpatialRenderScope { + private let conn: OpaquePointer + private let isInsideTransaction: Bool + + init?(connection: LibPQPluginConnection) { + connection.stateLock.lock() + let handle = connection.conn + connection.stateLock.unlock() + guard let handle else { return nil } + self.conn = handle + switch PQtransactionStatus(handle) { + case PQTRANS_IDLE: + isInsideTransaction = false + case PQTRANS_INTRANS: + guard LibPQPluginConnection.runCommand(PostGISSpatialRewrite.savepoint, on: handle) else { + return nil + } + isInsideTransaction = true + default: + return nil + } + } - let arrayLiteral = PostGISSpatialRewrite.arrayLiteral(from: hexValues) - guard let paramCStr = strdup(arrayLiteral) else { return nil } - defer { free(paramCStr) } + func convert(_ hexValues: [String?], query: String) -> [PluginCellValue]? { + let arrayLiteral = PostGISSpatialRewrite.arrayLiteral(from: hexValues) + guard let paramCStr = strdup(arrayLiteral) else { return nil } + defer { free(paramCStr) } - let paramValues: [UnsafePointer?] = [UnsafePointer(paramCStr)] - let result: OpaquePointer? = query.withCString { queryPtr in - PQexecParams(conn, queryPtr, 1, nil, paramValues, nil, nil, 0) + let paramValues: [UnsafePointer?] = [UnsafePointer(paramCStr)] + let result = query.withCString { queryPtr in + PQexecParams(conn, queryPtr, 1, nil, paramValues, nil, nil, 0) + } + guard let result, PQresultStatus(result) == PGRES_TUPLES_OK else { + if let result { PQclear(result) } + rollback() + return nil + } + defer { PQclear(result) } + return LibPQPluginConnection.textColumn(from: result) } - guard let result, PQresultStatus(result) == PGRES_TUPLES_OK else { - if let result { PQclear(result) } - return nil + func finish() { + guard isInsideTransaction else { return } + _ = LibPQPluginConnection.runCommand(PostGISSpatialRewrite.releaseSavepoint, on: conn) } - defer { PQclear(result) } + private func rollback() { + guard isInsideTransaction else { return } + _ = LibPQPluginConnection.runCommand(PostGISSpatialRewrite.rollbackToSavepoint, on: conn) + } + } + + private static func textColumn(from result: OpaquePointer) -> [PluginCellValue] { let rowCount = Int(PQntuples(result)) var converted: [PluginCellValue] = [] converted.reserveCapacity(rowCount) @@ -1143,7 +1326,7 @@ final class LibPQPluginConnection: @unchecked Sendable { } else if let valuePtr = PQgetvalue(result, Int32(rowIndex), 0) { let length = Int(PQgetlength(result, Int32(rowIndex), 0)) let bufferPtr = UnsafeRawBufferPointer(start: valuePtr, count: length) - converted.append(.text(String(bytes: bufferPtr, encoding: .utf8) ?? "")) + converted.append(.text(LibPQCellDecoding.text(from: bufferPtr))) } else { converted.append(.null) } @@ -1151,6 +1334,12 @@ final class LibPQPluginConnection: @unchecked Sendable { return converted } + private static func runCommand(_ command: String, on conn: OpaquePointer) -> Bool { + guard let result = command.withCString({ PQexec(conn, $0) }) else { return false } + defer { PQclear(result) } + return PQresultStatus(result) == PGRES_COMMAND_OK + } + private static func decodeCell( from result: OpaquePointer, row: Int32, @@ -1163,21 +1352,7 @@ final class LibPQPluginConnection: @unchecked Sendable { } let length = Int(PQgetlength(result, row, column)) - let bufferPtr = UnsafeRawBufferPointer(start: valuePtr, count: length) - - if oid == 17 { - let text = String(bytes: bufferPtr, encoding: .utf8) ?? "" - guard let data = LibPQByteaDecoder.decode(text) else { return .text(text) } - return .bytes(data) - } - - if oid == 16 { - let str = String(bytes: bufferPtr, encoding: .utf8) ?? "" - return .text(str == "t" ? "true" : "false") - } - - if let str = String(bytes: bufferPtr, encoding: .utf8) { return .text(str) } - return .text(String(bytes: bufferPtr, encoding: .isoLatin1) ?? "") + return LibPQCellDecoding.value(from: UnsafeRawBufferPointer(start: valuePtr, count: length), oid: oid) } private func parseRows( @@ -1233,6 +1408,70 @@ final class LibPQPluginConnection: @unchecked Sendable { // MARK: - Private Helpers + var hasLostConnection: Bool { + stateLock.withLock { _hasLostConnection } + } + + private var lastTransactionState: LibPQTransactionState { + stateLock.withLock { _lastTransactionState } + } + + private var sessionEndingMessage: LibPQPluginError? { + stateLock.withLock { serverMessages }?.takeUnretainedValue().sessionEndingMessage + } + + private func recordTransactionState(of conn: OpaquePointer) { + guard PQstatus(conn) == CONNECTION_OK else { return } + let state = Self.transactionState(PQtransactionStatus(conn)) + stateLock.withLock { _lastTransactionState = state } + } + + private func sessionEndedBeforeSending(_ conn: OpaquePointer) -> LibPQConnectionLostError? { + recordTransactionState(of: conn) + /// All three calls earn their place, measured against a terminated backend on 9.1.24 and + /// 17.11. One `PQconsumeInput` leaves the status `CONNECTION_OK`, so a single read never + /// sees the loss; the second one turns it `CONNECTION_BAD`. `PQisBusy` never moves the + /// status, but it is what parses the buffered message: without it every closed-session + /// case loses the server's FATAL and its SQLSTATE and reports libpq's own "server closed + /// the connection unexpectedly" instead. + if PQstatus(conn) == CONNECTION_OK { + _ = PQconsumeInput(conn) + _ = PQisBusy(conn) + _ = PQconsumeInput(conn) + } + guard PQstatus(conn) == CONNECTION_BAD else { + stateLock.withLock { serverMessages }?.takeUnretainedValue().clearIfHealthy() + return nil + } + let serverMessage = sessionEndingMessage + let loss = LibPQConnectionLoss(sent: false, recordedState: lastTransactionState) + stateLock.withLock { _hasLostConnection = true } + logger.info("Server closed the session before a statement was sent") + return LibPQConnectionLostError(loss: loss, underlying: serverMessage ?? getError(from: conn)) + } + + private func lostConnection(_ error: LibPQPluginError, on conn: OpaquePointer, sent: Bool) -> Error { + guard PQstatus(conn) == CONNECTION_BAD else { return error } + let loss = LibPQConnectionLoss(sent: sent, recordedState: lastTransactionState) + stateLock.withLock { + if sent { _lastTransactionState = .unknown } + _hasLostConnection = true + } + let phase = sent ? "while a statement was running" : "while sending a statement" + logger.warning("Connection lost \(phase, privacy: .public)") + return LibPQConnectionLostError(loss: loss, underlying: sessionEndingMessage ?? error) + } + + private static func transactionState(_ status: PGTransactionStatusType) -> LibPQTransactionState { + switch status { + case PQTRANS_IDLE: return .idle + case PQTRANS_ACTIVE: return .active + case PQTRANS_INTRANS: return .inTransaction + case PQTRANS_INERROR: return .inError + default: return .unknown + } + } + private func getError(from conn: OpaquePointer) -> LibPQPluginError { var message = "Unknown error" if let msgPtr = PQerrorMessage(conn) { @@ -1243,22 +1482,12 @@ final class LibPQPluginConnection: @unchecked Sendable { private func getResultError(from result: OpaquePointer) -> LibPQPluginError { var message = "Unknown error" - var sqlState: String? - var detail: String? - if let msgPtr = PQresultErrorMessage(result) { message = String(cString: msgPtr).trimmingCharacters(in: .whitespacesAndNewlines) } - - if let statePtr = PQresultErrorField(result, Int32(80)) { - sqlState = String(cString: statePtr) - } - - if let detailPtr = PQresultErrorField(result, Int32(68)) { - detail = String(cString: detailPtr) + return LibPQPluginError(message: message) { field in + PQresultErrorField(result, field).map { String(cString: $0) } } - - return LibPQPluginError(message: message, sqlState: sqlState, detail: detail) } private func getAffectedRows(from result: OpaquePointer) -> Int { @@ -1275,11 +1504,3 @@ final class LibPQPluginConnection: @unchecked Sendable { return nil } } - -// MARK: - PluginDriverError Conformance - -extension LibPQPluginError: PluginDriverError { - var pluginErrorMessage: String { message } - var pluginSqlState: String? { sqlState } - var pluginErrorDetail: String? { detail } -} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginError.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginError.swift new file mode 100644 index 0000000000..e2ad6aa6c1 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginError.swift @@ -0,0 +1,34 @@ +import Foundation +import TableProPluginKit + +struct LibPQPluginError: Error { + let message: String + let sqlState: String? + let detail: String? + + static let notConnected = LibPQPluginError( + message: String(localized: "Not connected to database"), sqlState: nil, detail: nil) + static let connectionFailed = LibPQPluginError( + message: String(localized: "Failed to establish connection"), sqlState: nil, detail: nil) + static let connectionTimedOut = LibPQPluginError( + message: String(localized: "Timed out while connecting to the server"), sqlState: nil, detail: nil) +} + +internal extension LibPQPluginError { + private static let sqlStateField = Int32(UInt8(ascii: "C")) + private static let detailField = Int32(UInt8(ascii: "D")) + + init(message: String, readingResultField readField: (Int32) -> String?) { + self.init( + message: message, + sqlState: readField(Self.sqlStateField), + detail: readField(Self.detailField) + ) + } +} + +extension LibPQPluginError: PluginDriverError { + var pluginErrorMessage: String { message } + var pluginSqlState: String? { sqlState } + var pluginErrorDetail: String? { detail } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift b/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift new file mode 100644 index 0000000000..4f93b98886 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift @@ -0,0 +1,58 @@ +// +// LibPQServerMessageSink.swift +// PostgreSQLDriverPlugin +// + +import CLibPQ +import Foundation + +final class LibPQServerMessageSink: @unchecked Sendable { + private static let severityField = Int32(UInt8(ascii: "V")) + + private let lock = NSLock() + private var sessionEnding: LibPQPluginError? + private var forwardedReceiver: PQnoticeReceiver? + + static func install(on connection: OpaquePointer) -> Unmanaged { + let retained = Unmanaged.passRetained(LibPQServerMessageSink()) + let previous = PQsetNoticeReceiver(connection, libpqServerMessageReceiver, retained.toOpaque()) + retained.takeUnretainedValue().lock.withLock { + retained.takeUnretainedValue().forwardedReceiver = previous + } + return retained + } + + var sessionEndingMessage: LibPQPluginError? { + lock.withLock { sessionEnding } + } + + /// Only ever called for a connection libpq still reports as `CONNECTION_OK`. A server before + /// 9.6 sends no non-localized severity, so a `RAISE WARNING ... ERRCODE '08006'` on a healthy + /// session reaches `receive` looking exactly like the real thing; a session that ended cannot + /// be healthy, so the healthy case is the one that must not keep the message. + func clearIfHealthy() { + lock.withLock { sessionEnding = nil } + } + + fileprivate func receive(_ result: OpaquePointer) { + let message = PQresultErrorMessage(result).map { + String(cString: $0).trimmingCharacters(in: .whitespacesAndNewlines) + } ?? "" + let received = LibPQPluginError(message: message) { field in + PQresultErrorField(result, field).map { String(cString: $0) } + } + let severity = PQresultErrorField(result, Self.severityField).map { String(cString: $0) } + let forward = lock.withLock { () -> PQnoticeReceiver? in + if LibPQServerMessage.endsSession(severity: severity, sqlState: received.sqlState) { + sessionEnding = received + } + return forwardedReceiver + } + forward?(nil, result) + } +} + +private func libpqServerMessageReceiver(_ context: UnsafeMutableRawPointer?, _ result: OpaquePointer?) { + guard let context, let result else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().receive(result) +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQStringConformance.swift b/Plugins/PostgreSQLDriverPlugin/LibPQStringConformance.swift new file mode 100644 index 0000000000..0227cf52c5 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQStringConformance.swift @@ -0,0 +1,35 @@ +// +// LibPQStringConformance.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +enum LibPQStringConformance { + static let parameterName = "standard_conforming_strings" + + static let enableStatement = "SET standard_conforming_strings TO on" + + static let showQuery = "SHOW standard_conforming_strings" + + static func isOn(_ reportedValue: String?) -> Bool? { + guard let reportedValue else { return nil } + let normalized = reportedValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "on", "true", "yes", "1": + return true + case "off", "false", "no", "0": + return false + default: + return nil + } + } + + static func escape(_ value: String, standardConformingStrings: Bool) -> String { + let quoted = value + .replacingOccurrences(of: "\0", with: "") + .replacingOccurrences(of: "'", with: "''") + guard !standardConformingStrings else { return quoted } + return quoted.replacingOccurrences(of: "\\", with: "\\\\") + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift b/Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift index 1085ba8917..f17fb6cfbb 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift @@ -14,20 +14,31 @@ import Foundation +struct PostGISType: Equatable, Sendable { + let name: String + let schema: String +} + enum PostGISSpatialRewrite { - static let probeQuery = "SELECT oid, typname FROM pg_type WHERE typname IN ('geometry', 'geography')" + static let probeQuery = """ + SELECT t.oid, t.typname, n.nspname + FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname IN ('geometry', 'geography') + """ - static let geometryConversionQuery = - "SELECT ST_AsEWKT(t::geometry) FROM unnest($1::text[]) WITH ORDINALITY AS x(t, ord) ORDER BY ord" - static let geographyConversionQuery = - "SELECT ST_AsEWKT(t::geography) FROM unnest($1::text[]) WITH ORDINALITY AS x(t, ord) ORDER BY ord" + /// A user savepoint of the same name is safe: PostgreSQL resolves a repeated savepoint name to + /// the newest one, so this RELEASE removes only the savepoint the rendering pass opened. + static let savepoint = "SAVEPOINT tablepro_spatial_render" + static let releaseSavepoint = "RELEASE SAVEPOINT tablepro_spatial_render" + static let rollbackToSavepoint = "ROLLBACK TO SAVEPOINT tablepro_spatial_render" - static func conversionQuery(forTypeName typeName: String) -> String? { - switch typeName { - case "geometry": return geometryConversionQuery - case "geography": return geographyConversionQuery - default: return nil - } + static func conversionQuery(for type: PostGISType) -> String? { + guard type.name == "geometry" || type.name == "geography" else { return nil } + let schema = PostgreSQLObjectQueries.quoteIdentifier(type.schema) + let qualifiedType = PostgreSQLObjectQueries.qualifiedName(schema: type.schema, name: type.name) + return "SELECT \(schema).ST_AsEWKT(($1::text[])[i]::\(qualifiedType)) " + + "FROM pg_catalog.generate_subscripts($1::text[], 1) AS i ORDER BY i" } static func arrayLiteral(from values: [String?]) -> String { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift index 9ae3b4ae29..898ccb8562 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift @@ -5,11 +5,9 @@ import Foundation -struct PostgreSQLCapabilities: Sendable, Equatable { +nonisolated struct PostgreSQLCapabilities: Sendable, Equatable { let serverVersion: Int32 - static let unknown = PostgreSQLCapabilities(serverVersion: 0) - /// libpq answers 0 for a handle it has not connected. A catalog query built for that has to /// assume a current server, or it emits the legacy projection on every server that exists. static func assumingModernWhenUnknown(_ serverVersion: Int32) -> PostgreSQLCapabilities { @@ -18,7 +16,6 @@ struct PostgreSQLCapabilities: Sendable, Equatable { var hasMaterializedViewsCatalog: Bool { serverVersion >= 90_300 } var hasRangeTypes: Bool { serverVersion >= 90_200 } - var hasJsonBuildObject: Bool { serverVersion >= 90_400 } var hasEnumLabelPlacement: Bool { serverVersion >= 90_100 } /// ADD VALUE IF NOT EXISTS landed in 9.3. With it an add is idempotent, which is what makes /// the driver's one reconnect-and-resend safe for a label that already landed. @@ -28,8 +25,9 @@ struct PostgreSQLCapabilities: Sendable, Equatable { /// Every range gained a companion multirange in 14, with a name the creator may choose. var hasMultirangeTypes: Bool { serverVersion >= 140_000 } var hasForeignTablesCatalog: Bool { serverVersion >= 90_100 } - var hasSequencesCatalog: Bool { serverVersion >= 90_500 } + var hasSequencesCatalog: Bool { serverVersion >= 100_000 } var hasBypassRLS: Bool { serverVersion >= 90_500 } + var hasProcedureKind: Bool { serverVersion >= 110_000 } var hasIdentityColumns: Bool { serverVersion >= 100_000 } var hasGeneratedColumns: Bool { serverVersion >= 120_000 } @@ -39,13 +37,22 @@ struct PostgreSQLCapabilities: Sendable, Equatable { /// Virtual generated columns landed in 18; VIRTUAL is a syntax error on every earlier server. var hasVirtualGeneratedColumns: Bool { serverVersion >= 180_000 } var hasDeclarativePartitioning: Bool { serverVersion >= 100_000 } - - var hasArrayPosition: Bool { serverVersion >= 90_500 } - var hasOrderedAggregates: Bool { serverVersion >= 90_000 } + /// A foreign key that reaches a partitioned table is stored once per partition from PostgreSQL + /// 12, and each clone points at the constraint it was cloned from through `conparentid`, a + /// column that exists from 11. + var hasConstraintParent: Bool { serverVersion >= 110_000 } var hasCollationProvider: Bool { serverVersion >= 100_000 } var hasDatabaseICULocale: Bool { serverVersion >= 150_000 } var hasDatabaseLocale: Bool { serverVersion >= 170_000 } var hasModernICUSyntax: Bool { serverVersion >= 160_000 } + + var hasRenameConstraint: Bool { serverVersion >= 90_200 } + var hasCreateSchemaIfNotExists: Bool { serverVersion >= 90_300 } + var hasBrinIndexes: Bool { serverVersion >= 90_500 } + var hasExecuteFunctionTriggerSyntax: Bool { serverVersion >= 110_000 } + var hasReindexConcurrently: Bool { serverVersion >= 120_000 } + var hasCreateOrReplaceTrigger: Bool { serverVersion >= 140_000 } + var hasUnnamedReindexDatabase: Bool { serverVersion >= 160_000 } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift new file mode 100644 index 0000000000..4e1ed30ef8 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift @@ -0,0 +1,15 @@ +// +// PostgreSQLCatalogBoolean.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +enum PostgreSQLCatalogBoolean { + private static let trueSpellings: Set = ["t", "true", "yes", "on", "1"] + + static func isTrue(_ text: String?) -> Bool { + guard let text else { return false } + return trueSpellings.contains(text.lowercased()) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogForeignKeys.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogForeignKeys.swift new file mode 100644 index 0000000000..bda37195eb --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogForeignKeys.swift @@ -0,0 +1,194 @@ +import Foundation +import TableProPluginKit + +nonisolated enum PostgreSQLCatalogForeignKeys { + static let constraintParentMinimumServerVersion: Int32 = 110_000 + + enum Column: Int, CaseIterable { + case constraintIdentity + case constraintName + case referencedSchema + case referencedTable + case deleteAction + case updateAction + case sourceKeys + case referencedKeys + case side + case attributeNumber + case attributeName + } + + enum Side: String, CaseIterable { + case source = "s" + case referenced = "r" + + fileprivate var relationColumn: String { + switch self { + case .source: return "c.conrelid" + case .referenced: return "c.confrelid" + } + } + + fileprivate var keyColumn: String { + switch self { + case .source: return "c.conkey" + case .referenced: return "c.confkey" + } + } + } + + static func query(schema: String, table: String, excludesPartitionClones: Bool) -> String { + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + let cloneFilter = excludesPartitionClones ? """ + + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_constraint parent + WHERE parent.oid = c.conparentid + AND parent.conrelid = c.conrelid) + """ : "" + let branches = Side.allCases.map { side in + """ + SELECT c.oid, c.conname, ref_ns.nspname, ref_cl.relname, c.confdeltype, c.confupdtype, + c.conkey, c.confkey, \(PostgreSQLObjectQueries.quoteLiteral(side.rawValue)), a.attnum, a.attname + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_class cl ON cl.oid = c.conrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace + JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = c.confrelid + JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace + JOIN pg_catalog.pg_attribute a ON a.attrelid = \(side.relationColumn) AND a.attnum = ANY (\(side.keyColumn)) + WHERE c.contype = 'f' + AND ns.nspname = \(schemaLiteral) + AND cl.relname = \(tableLiteral)\(cloneFilter) + """ + } + return branches.joined(separator: "\nUNION ALL\n") + "\nORDER BY 2, 1" + } + + static func excludesPartitionClones(serverVersionNumber: Int32) -> Bool { + serverVersionNumber >= constraintParentMinimumServerVersion + } + + static func foreignKeys(from rows: [[String?]]) -> [PluginForeignKeyInfo] { + var identities: [String] = [] + var constraints: [String: CatalogForeignKey] = [:] + for row in rows { + guard let keyRow = CatalogKeyRow(row) else { continue } + if constraints[keyRow.constraintIdentity] == nil { + identities.append(keyRow.constraintIdentity) + constraints[keyRow.constraintIdentity] = CatalogForeignKey(keyRow) + } + constraints[keyRow.constraintIdentity]?.record(keyRow) + } + return identities.flatMap { constraints[$0]?.pairs ?? [] } + } + + static func referentialAction(_ code: String?) -> String { + switch code { + case "r": return "RESTRICT" + case "c": return "CASCADE" + case "n": return "SET NULL" + case "d": return "SET DEFAULT" + default: return "NO ACTION" + } + } +} + +nonisolated private struct CatalogKeyRow { + let constraintIdentity: String + let constraintName: String + let referencedSchema: String? + let referencedTable: String + let deleteAction: String? + let updateAction: String? + let sourceKeys: [Int] + let referencedKeys: [Int] + let side: PostgreSQLCatalogForeignKeys.Side + let attributeNumber: Int + let attributeName: String + + init?(_ row: [String?]) { + typealias Column = PostgreSQLCatalogForeignKeys.Column + guard row.count >= Column.allCases.count, + let identity = row[Column.constraintIdentity.rawValue], + let name = row[Column.constraintName.rawValue], + let referencedTable = row[Column.referencedTable.rawValue], + let sourceKeys = Self.attributeNumbers(row[Column.sourceKeys.rawValue]), + let referencedKeys = Self.attributeNumbers(row[Column.referencedKeys.rawValue]), + let sideCode = row[Column.side.rawValue], + let side = PostgreSQLCatalogForeignKeys.Side(rawValue: sideCode), + let attributeNumberText = row[Column.attributeNumber.rawValue], + let attributeNumber = Int(attributeNumberText), + let attributeName = row[Column.attributeName.rawValue] + else { return nil } + self.constraintIdentity = identity + self.constraintName = name + self.referencedSchema = row[Column.referencedSchema.rawValue] + self.referencedTable = referencedTable + self.deleteAction = row[Column.deleteAction.rawValue] + self.updateAction = row[Column.updateAction.rawValue] + self.sourceKeys = sourceKeys + self.referencedKeys = referencedKeys + self.side = side + self.attributeNumber = attributeNumber + self.attributeName = attributeName + } + + private static func attributeNumbers(_ text: String?) -> [Int]? { + guard let text, let elements = PostgresArrayLiteralCodec.parse(text) else { return nil } + var numbers: [Int] = [] + for element in elements { + guard case .value(let value) = element, let number = Int(value) else { return nil } + numbers.append(number) + } + return numbers + } +} + +nonisolated private struct CatalogForeignKey { + let name: String + let referencedSchema: String? + let referencedTable: String + let onDelete: String + let onUpdate: String + let sourceKeys: [Int] + let referencedKeys: [Int] + var sourceNames: [Int: String] = [:] + var referencedNames: [Int: String] = [:] + + init(_ row: CatalogKeyRow) { + self.name = row.constraintName + self.referencedSchema = row.referencedSchema + self.referencedTable = row.referencedTable + self.onDelete = PostgreSQLCatalogForeignKeys.referentialAction(row.deleteAction) + self.onUpdate = PostgreSQLCatalogForeignKeys.referentialAction(row.updateAction) + self.sourceKeys = row.sourceKeys + self.referencedKeys = row.referencedKeys + } + + mutating func record(_ row: CatalogKeyRow) { + switch row.side { + case .source: sourceNames[row.attributeNumber] = row.attributeName + case .referenced: referencedNames[row.attributeNumber] = row.attributeName + } + } + + var pairs: [PluginForeignKeyInfo] { + guard sourceKeys.count == referencedKeys.count else { return [] } + return zip(sourceKeys, referencedKeys).compactMap { sourceKey, referencedKey in + guard let column = sourceNames[sourceKey], let referencedColumn = referencedNames[referencedKey] else { + return nil + } + return PluginForeignKeyInfo( + name: name, + column: column, + referencedTable: referencedTable, + referencedColumn: referencedColumn, + referencedSchema: referencedSchema, + onDelete: onDelete, + onUpdate: onUpdate + ) + } + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCommentStatements.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCommentStatements.swift new file mode 100644 index 0000000000..ad26d20e43 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCommentStatements.swift @@ -0,0 +1,79 @@ +// +// PostgreSQLCommentStatements.swift +// PostgreSQLDriverPlugin +// +// The COMMENT statements that reattach one relation's comment and its column comments. Pure, so it +// is testable without a server. +// + +import Foundation + +public enum PostgreSQLCommentStatements { + /// The relation's own comment first, then one row per commented column in `attnum` order, which + /// is the order `pg_dump` writes them in. + /// + /// `relkind` rides on every row, the column rows included, because it is what decides the + /// keyword `COMMENT ON` takes and a relation with no comment of its own still has columns to + /// write. A row with no description is filtered out here rather than rendered as `IS NULL`, which + /// would erase a comment instead of restoring one. + public static func catalogQuery(name: String, schema: String) -> String { + let nameLiteral = PostgreSQLObjectQueries.quoteLiteral(name) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + return """ + SELECT relkind, attname, description + FROM ( + SELECT + c.relkind::text AS relkind, + NULL::text AS attname, + pg_catalog.obj_description(c.oid, 'pg_class') AS description, + 0 AS ordinal, + 0 AS attnum + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = \(schemaLiteral) + AND c.relname = \(nameLiteral) + AND c.relkind IN ('r', 'p', 'f', 'v', 'm') + AND pg_catalog.obj_description(c.oid, 'pg_class') IS NOT NULL + UNION ALL + SELECT + c.relkind::text AS relkind, + a.attname AS attname, + pg_catalog.col_description(c.oid, a.attnum) AS description, + 1 AS ordinal, + a.attnum AS attnum + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid + WHERE n.nspname = \(schemaLiteral) + AND c.relname = \(nameLiteral) + AND c.relkind IN ('r', 'p', 'f', 'v', 'm') + AND a.attnum > 0 + AND NOT a.attisdropped + AND pg_catalog.col_description(c.oid, a.attnum) IS NOT NULL + ) AS relation_comments + ORDER BY ordinal, attnum + """ + } + + /// Renders what `catalogQuery` answered. Rows are projected as `relkind`, `attname`, + /// `description`, and a nil `attname` marks the relation's own comment. + public static func statements(name: String, schema: String, rows: [[String?]]) -> [String] { + let target = PostgreSQLObjectQueries.qualifiedName(schema: schema, name: name) + return rows.compactMap { statement(target: target, row: $0) } + } + + private static func statement(target: String, row: [String?]) -> String? { + guard row.count >= 3, + let relkind = row[0], + let keyword = PostgreSQLRelationSQL.commentKeyword(forRelkind: relkind), + let description = row[2], + !description.isEmpty + else { return nil } + let literal = PostgreSQLObjectQueries.quoteLiteral(description) + guard let column = row[1], !column.isEmpty else { + return "COMMENT ON \(keyword) \(target) IS \(literal)" + } + let columnRef = "\(target).\(PostgreSQLObjectQueries.quoteIdentifier(column))" + return "COMMENT ON COLUMN \(columnRef) IS \(literal)" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLForeignKeyQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLForeignKeyQueries.swift new file mode 100644 index 0000000000..456773a432 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLForeignKeyQueries.swift @@ -0,0 +1,88 @@ +// +// PostgreSQLForeignKeyQueries.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum PostgreSQLForeignKeyQueries { + static func foreignKeyList(schema: String, table: String?, capabilities: PostgreSQLCapabilities) -> String { + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + let tablePredicate = table.map { "\n AND src.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" + let clonePredicate = capabilities.hasConstraintParent ? """ + + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_constraint parent + WHERE parent.oid = c.conparentid + AND parent.conrelid = c.conrelid + ) + """ : "" + return """ + SELECT + src_cl.relname AS table_name, + con.conname, + src_col.attname, + ref_cl.relname AS referenced_table, + ref_col.attname AS referenced_column, + ref_ns.nspname AS referenced_schema, + \(referentialAction("con.confdeltype")) AS delete_rule, + \(referentialAction("con.confupdtype")) AS update_rule + FROM ( + SELECT c.conname, c.conrelid, c.confrelid, c.confdeltype, c.confupdtype, c.conkey, c.confkey, + pg_catalog.generate_subscripts(c.conkey, 1) AS ord + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_class src ON src.oid = c.conrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid = src.relnamespace + WHERE c.contype = 'f' + AND ns.nspname = \(schemaLiteral)\(tablePredicate)\(clonePredicate) + ) con + JOIN pg_catalog.pg_class src_cl ON src_cl.oid = con.conrelid + JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = con.confrelid + JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace + JOIN pg_catalog.pg_attribute src_col + ON src_col.attrelid = con.conrelid AND src_col.attnum = con.conkey[con.ord] + JOIN pg_catalog.pg_attribute ref_col + ON ref_col.attrelid = con.confrelid AND ref_col.attnum = con.confkey[con.ord] + ORDER BY src_cl.relname, con.conname, con.ord + """ + } + + private static func referentialAction(_ column: String) -> String { + """ + CASE \(column) + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + WHEN 'r' THEN 'RESTRICT' + ELSE 'NO ACTION' + END + """ + } +} + +struct PostgreSQLForeignKeyRow { + let table: String + let foreignKey: PluginForeignKeyInfo + + init?(_ row: [PluginCellValue]) { + guard row.count >= 8, + let table = row[0].asText, + let name = row[1].asText, + let column = row[2].asText, + let referencedTable = row[3].asText, + let referencedColumn = row[4].asText + else { return nil } + self.table = table + self.foreignKey = PluginForeignKeyInfo( + name: name, + column: column, + referencedTable: referencedTable, + referencedColumn: referencedColumn, + referencedSchema: row[5].asText, + onDelete: row[6].asText ?? "NO ACTION", + onUpdate: row[7].asText ?? "NO ACTION" + ) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift new file mode 100644 index 0000000000..81e44ce28b --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift @@ -0,0 +1,54 @@ +// +// PostgreSQLIndexQueries.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum PostgreSQLIndexQueries { + static func indexList(schema: String, table: String?) -> String { + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + let tablePredicate = table.map { " AND t.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" + return """ + SELECT + t.relname AS table_name, + i.relname AS index_name, + ARRAY_AGG(a.attname ORDER BY ( + SELECT min(k) + FROM pg_catalog.generate_subscripts(ix.indkey, 1) AS k + WHERE ix.indkey[k] = a.attnum + ))::text AS columns, + ix.indisunique AS is_unique, + ix.indisprimary AS is_primary, + am.amname AS index_type, + pg_catalog.pg_get_expr(ix.indpred, ix.indrelid) AS predicate + FROM pg_catalog.pg_index ix + JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid + JOIN pg_catalog.pg_class t ON t.oid = ix.indrelid + JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace + JOIN pg_catalog.pg_am am ON am.oid = i.relam + JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) + WHERE n.nspname = \(schemaLiteral)\(tablePredicate) + GROUP BY t.relname, i.relname, ix.indisunique, ix.indisprimary, am.amname, ix.indpred, ix.indrelid + ORDER BY t.relname, ix.indisprimary DESC, i.relname + """ + } +} + +enum PostgreSQLIndexRow { + static func index(from row: [PluginCellValue]) -> (table: String, index: PluginIndexInfo)? { + guard let table = row[safe: 0]?.asText, + let name = row[safe: 1]?.asText, + let columnsText = row[safe: 2]?.asText else { return nil } + let index = PluginIndexInfo( + name: name, + columns: PostgreSQLTextArray.values(columnsText), + isUnique: PostgreSQLCatalogBoolean.isTrue(row[safe: 3]?.asText), + isPrimary: PostgreSQLCatalogBoolean.isTrue(row[safe: 4]?.asText), + type: row[safe: 5]?.asText?.uppercased() ?? "BTREE", + whereClause: row[safe: 6]?.asText + ) + return (table, index) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLMaintenance.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLMaintenance.swift new file mode 100644 index 0000000000..0253f0adfc --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLMaintenance.swift @@ -0,0 +1,123 @@ +// +// PostgreSQLMaintenance.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// PostgreSQL's maintenance operations and the statements they produce. +/// +/// Pure, so the confirmation sheet's preview and the statement that runs are one function rather +/// than two implementations that drifted: the sheet used to print `REINDEX orders`, which is not +/// valid SQL, where `REINDEX TABLE "orders"` ran. +/// +/// Every kind set below is what PostgreSQL 17.11 answered to the statement on that kind. The two +/// that are not obvious: `CLUSTER` is accepted on a partitioned table and can never succeed there, +/// because `ALTER TABLE … CLUSTER ON` refuses with "cannot mark index clustered in partitioned +/// table"; and `ANALYZE` on a foreign table succeeds with no warning and writes `pg_statistic` rows, +/// because the wrapper samples it, while `VACUUM` on the same table warns and skips. +internal enum PostgreSQLMaintenance { + internal static let vacuum = "VACUUM" + internal static let analyze = "ANALYZE" + internal static let reindex = "REINDEX" + internal static let cluster = "CLUSTER" + + internal static let verboseKey = "verbose" + internal static let fullKey = "full" + internal static let analyzeKey = "analyze" + + internal static let operations: [PluginMaintenanceOperation] = [ + PluginMaintenanceOperation( + name: vacuum, + appliesTo: [.table, .partitionedTable, .materializedView], + scope: .objectOrDatabase, + options: [ + PluginMaintenanceOption( + key: fullKey, + label: String(localized: "FULL (rewrites the whole object, blocks access)"), + defaultValue: "false" + ), + PluginMaintenanceOption( + key: analyzeKey, + label: String(localized: "ANALYZE (update statistics afterwards)"), + defaultValue: "false" + ), + PluginMaintenanceOption( + key: verboseKey, + label: String(localized: "VERBOSE (report progress)"), + defaultValue: "false" + ) + ] + ), + PluginMaintenanceOperation( + name: analyze, + appliesTo: [.table, .partitionedTable, .materializedView, .foreignTable], + scope: .objectOrDatabase + ), + PluginMaintenanceOperation( + name: reindex, + appliesTo: [.table, .partitionedTable, .materializedView], + scope: .objectOrDatabase, + options: [ + PluginMaintenanceOption( + key: verboseKey, + label: String(localized: "VERBOSE (report progress)"), + defaultValue: "false" + ) + ] + ), + PluginMaintenanceOperation( + name: cluster, + appliesTo: [.table, .materializedView], + scope: .object + ) + ] + + /// A nil `schema` leaves the name unqualified, which is what a caller with no schema to offer + /// means. Everything in the app does have one, and has to pass it: `search_path` resolves a bare + /// name against `pg_temp` first, so a temp table of the same name is what got maintained. + internal static func statements( + operation: String, + table: String?, + schema: String?, + options: [String: String], + connectedDatabase: String?, + capabilities: PostgreSQLCapabilities + ) -> [String]? { + let target = table.map { qualified(table: $0, schema: schema) } + switch operation { + case vacuum: + var flags: [String] = [] + if isOn(options[fullKey]) { flags.append("FULL") } + if isOn(options[analyzeKey]) { flags.append("ANALYZE") } + if isOn(options[verboseKey]) { flags.append("VERBOSE") } + let clause = flags.isEmpty ? "" : " (\(flags.joined(separator: ", ")))" + return [target.map { "VACUUM\(clause) \($0)" } ?? "VACUUM\(clause)"] + case analyze: + return [target.map { "ANALYZE \($0)" } ?? "ANALYZE"] + case reindex: + guard let target else { + return PostgreSQLVersionedStatements.reindexDatabase( + currentDatabase: connectedDatabase, + capabilities: capabilities + ).map { [$0] } + } + let clause = isOn(options[verboseKey]) ? " (VERBOSE)" : "" + return ["REINDEX\(clause) TABLE \(target)"] + case cluster: + return target.map { ["CLUSTER \($0)"] } + default: + return nil + } + } + + private static func qualified(table: String, schema: String?) -> String { + guard let schema, !schema.isEmpty else { return PostgreSQLObjectQueries.quoteIdentifier(table) } + return PostgreSQLObjectQueries.qualifiedName(schema: schema, name: table) + } + + private static func isOn(_ value: String?) -> Bool { + value == "true" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift index b7b5f544eb..725bb13405 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift @@ -9,21 +9,30 @@ import Foundation import TableProPluginKit -public enum PostgreSQLObjectQueries { - public static func escapeLiteral(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "''") +nonisolated public enum PostgreSQLObjectQueries { + /// The single owner of literal quoting for every statement this plugin builds, which is why it + /// returns the quotes too: the `E` prefix sits outside them, so a helper that returns inner text + /// for a call site to wrap can never be setting-independent. Measured on PostgreSQL 17.11 with + /// `standard_conforming_strings = off`: a schema named `a\b` listed as `'a\b'` returns no rows, + /// and one named `x\' OR true--` listed that way closes the literal after `x'` and runs + /// `OR true` as SQL, which turned a one-row listing into every relation in the database. + /// + /// Doubling the quote is enough while the value holds no backslash, and the output is then + /// byte-identical to plain quote doubling. With one, the value is written as an `E''` string, + /// where a backslash is always an escape whatever the setting says, and is doubled here. This is + /// what the server's own `quote_literal` emits for the same values. + /// + /// A NUL is dropped rather than escaped. libpq takes a NUL-terminated C string, so a NUL would + /// truncate the statement, and PostgreSQL rejects `\000` in a literal outright ("invalid byte + /// sequence for encoding UTF8"). This matches the PluginKit default's own NUL strip. + public static func quoteLiteral(_ value: String) -> String { + let stripped = value.replacingOccurrences(of: "\0", with: "") + guard stripped.contains("\\") else { return "'\(escapeQuotes(stripped))'" } + return "E'\(escapeQuotes(stripped.replacingOccurrences(of: "\\", with: "\\\\")))'" } - /// A literal that reads the same whatever `standard_conforming_strings` is set to. Doubling the - /// quote is enough while the value has no backslash; with one, a server running the legacy - /// setting would let `\'` swallow a doubled quote and close the literal early, so such a value - /// is written as an `E''` string, where a backslash is always an escape and is doubled here. - public static func quoteLiteral(_ value: String) -> String { - guard value.contains("\\") else { return "'\(escapeLiteral(value))'" } - let escaped = value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "''") - return "E'\(escaped)'" + private static func escapeQuotes(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") } public static func quoteIdentifier(_ name: String) -> String { @@ -34,16 +43,6 @@ public enum PostgreSQLObjectQueries { "\(quoteIdentifier(schema)).\(quoteIdentifier(name))" } - /// `prokind` arrived in PostgreSQL 11, which is also the first release with procedures. - public static let prokindMinimumServerVersion: Int32 = 110_000 - - /// libpq answers 0 for a handle it has not connected, so an unknown version has to read as - /// modern. Reading it as ancient emits `proisagg`, a column PostgreSQL 11 removed, and the - /// whole routine list fails on every current server. - public static func usesProkind(serverVersionNumber: Int32) -> Bool { - serverVersionNumber <= 0 || serverVersionNumber >= prokindMinimumServerVersion - } - /// Reads pg_proc rather than information_schema.routines. information_schema shows only what /// the current user has a privilege on, and its routine_name repeats across overloads with no /// column that separates them; pg_proc has one row per routine and an oid that does. @@ -51,9 +50,9 @@ public enum PostgreSQLObjectQueries { /// Aggregates (prokind 'a') and window functions ('w') are excluded because /// pg_get_functiondef raises on them, which would fail the whole listing over one object the /// viewer could not have shown anyway. - public static func routineList(schema: String, serverVersionNumber: Int32) -> String { - let schemaLiteral = escapeLiteral(schema) - let modern = usesProkind(serverVersionNumber: serverVersionNumber) + static func routineList(schema: String, capabilities: PostgreSQLCapabilities) -> String { + let schemaLiteral = quoteLiteral(schema) + let modern = capabilities.hasProcedureKind let kindColumn = modern ? "p.prokind" : "CASE WHEN p.proisagg THEN 'a' WHEN p.proiswindow THEN 'w' ELSE 'f' END" @@ -75,7 +74,7 @@ public enum PostgreSQLObjectQueries { FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace JOIN pg_catalog.pg_language l ON l.oid = p.prolang - WHERE n.nspname = '\(schemaLiteral)' + WHERE n.nspname = \(schemaLiteral) AND \(kindFilter) AND NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_depend d @@ -89,22 +88,22 @@ public enum PostgreSQLObjectQueries { /// instead of whichever row the planner happened to return first. public static func routineDefinition(identity: String) -> String { """ - SELECT pg_catalog.pg_get_functiondef('\(escapeLiteral(identity))'::oid) + SELECT pg_catalog.pg_get_functiondef(\(quoteLiteral(identity))::oid) """ } public static func routineDefinitionByName(name: String, schema: String, arguments: String?) -> String { - let nameLiteral = escapeLiteral(name) - let schemaLiteral = escapeLiteral(schema) + let nameLiteral = quoteLiteral(name) + let schemaLiteral = quoteLiteral(schema) let argumentsPredicate = arguments.map { - "AND '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' = '\(escapeLiteral($0))'" + "AND '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' = \(quoteLiteral($0))" } ?? "" return """ SELECT pg_catalog.pg_get_functiondef(p.oid) FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = '\(schemaLiteral)' - AND p.proname = '\(nameLiteral)' + WHERE n.nspname = \(schemaLiteral) + AND p.proname = \(nameLiteral) \(argumentsPredicate) ORDER BY p.oid LIMIT 1 @@ -114,8 +113,8 @@ public enum PostgreSQLObjectQueries { /// One query for the whole schema. The per-table fetch is the same SELECT with one more /// predicate, so the two lists cannot disagree about a table they both cover. public static func triggerList(schema: String, table: String?) -> String { - let schemaLiteral = escapeLiteral(schema) - let tablePredicate = table.map { "AND c.relname = '\(escapeLiteral($0))'" } ?? "" + let schemaLiteral = quoteLiteral(schema) + let tablePredicate = table.map { "AND c.relname = \(quoteLiteral($0))" } ?? "" return """ SELECT t.tgname AS name, @@ -124,12 +123,12 @@ public enum PostgreSQLObjectQueries { CASE WHEN (t.tgtype & 64) != 0 THEN 'INSTEAD OF' WHEN (t.tgtype & 2) != 0 THEN 'BEFORE' ELSE 'AFTER' END AS timing, - array_to_string(array_remove(ARRAY[ + concat_ws(' OR ', CASE WHEN (t.tgtype & 4) != 0 THEN 'INSERT' END, CASE WHEN (t.tgtype & 8) != 0 THEN 'DELETE' END, CASE WHEN (t.tgtype & 16) != 0 THEN 'UPDATE' END, CASE WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' END - ], NULL), ' OR ') AS event, + ) AS event, CASE WHEN (t.tgtype & 1) != 0 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, t.tgenabled <> 'D' AS enabled, pg_catalog.pg_get_triggerdef(t.oid) AS definition, @@ -137,7 +136,7 @@ public enum PostgreSQLObjectQueries { FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '\(schemaLiteral)' + WHERE n.nspname = \(schemaLiteral) AND NOT t.tgisinternal \(tablePredicate) ORDER BY c.relname, t.tgname @@ -155,26 +154,14 @@ public enum PostgreSQLObjectQueries { /// /// A listing names a schema; a reload names an oid and no schema, because a type keeps its oid /// when it is moved. Naming neither would list the whole database, so a caller passes one. - public static func userDefinedTypeList(schema: String?, identity: String?, serverVersionNumber: Int32) -> String { - let capabilities = PostgreSQLCapabilities.assumingModernWhenUnknown(serverVersionNumber) + static func userDefinedTypeList(schema: String?, identity: String?, capabilities: PostgreSQLCapabilities) -> String { let schemaPredicate = schema.map { "AND n.nspname = \(quoteLiteral($0))" } ?? "" let identityPredicate = identity.flatMap { UInt32($0) }.map { "AND t.oid = \($0)::oid" } ?? "" let kinds = capabilities.hasRangeTypes ? "('e', 'c', 'd', 'r')" : "('e', 'c', 'd')" - let fields = capabilities.hasJsonBuildObject ? """ - (SELECT json_agg(json_build_object( - 'name', a.attname, - 'type', pg_catalog.format_type(a.atttypid, a.atttypmod), - 'collation', CASE WHEN a.attcollation <> 0 AND a.attcollation <> ty.typcollation - THEN \(collationName("a.attcollation")) END) ORDER BY a.attnum) - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_type ty ON ty.oid = a.atttypid - WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped)::text - """ : "NULL::text" - let constraints = capabilities.hasJsonBuildObject ? """ - (SELECT json_agg(json_build_object('name', con.conname, 'definition', pg_catalog.pg_get_constraintdef(con.oid)) ORDER BY con.conname) - FROM pg_catalog.pg_constraint con - WHERE con.contypid = t.oid AND con.contype = 'c')::text - """ : "NULL::text" + let fieldCollation = """ + CASE WHEN a.attcollation <> 0 AND a.attcollation <> ty.typcollation + THEN \(collationName("a.attcollation")) END + """ let rangeJoin = capabilities.hasRangeTypes ? "LEFT JOIN pg_catalog.pg_range r ON r.rngtypid = t.oid" : "" let rangeSubtype = capabilities.hasRangeTypes ? "CASE WHEN t.typtype = 'r' THEN pg_catalog.format_type(r.rngsubtype, NULL) END" @@ -215,9 +202,11 @@ public enum PostgreSQLObjectQueries { t.typtype::text AS kind, pg_catalog.pg_get_userbyid(t.typowner) AS owner, pg_catalog.obj_description(t.oid, 'pg_type') AS comment, - (SELECT json_agg(e.enumlabel ORDER BY e.enumsortorder) + (SELECT array_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid)::text AS enum_labels, - \(fields) AS fields, + \(compositeFieldArray("a.attname")) AS field_names, + \(compositeFieldArray("pg_catalog.format_type(a.atttypid, a.atttypmod)")) AS field_types, + \(compositeFieldArray(fieldCollation, joinsType: true)) AS field_collations, CASE WHEN t.typtype = 'd' THEN pg_catalog.format_type(t.typbasetype, t.typtypmod) END AS base_type, CASE WHEN t.typtype = 'd' AND t.typcollation <> 0 AND t.typcollation <> (SELECT b.typcollation FROM pg_catalog.pg_type b WHERE b.oid = t.typbasetype) @@ -225,7 +214,8 @@ public enum PostgreSQLObjectQueries { END AS collation, t.typnotnull::text AS not_null, t.typdefault AS default_value, - \(constraints) AS constraints, + \(domainCheckArray("con.conname")) AS constraint_names, + \(domainCheckArray("pg_catalog.pg_get_constraintdef(con.oid)")) AS constraint_definitions, \(rangeSubtype) AS range_subtype, \(rangeCanonical) AS range_canonical, \(rangeSubtypeDiff) AS range_subtype_diff, @@ -249,6 +239,25 @@ public enum PostgreSQLObjectQueries { """ } + /// The three field arrays are zipped by position, so each one orders by `attnum` over exactly + /// the same rows. Only the collation expression reads `pg_type`, and it joins it itself. + private static func compositeFieldArray(_ expression: String, joinsType: Bool = false) -> String { + let typeJoin = joinsType ? "\n JOIN pg_catalog.pg_type ty ON ty.oid = a.atttypid" : "" + return """ + (SELECT array_agg(\(expression) ORDER BY a.attnum) + FROM pg_catalog.pg_attribute a\(typeJoin) + WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped)::text + """ + } + + private static func domainCheckArray(_ expression: String) -> String { + """ + (SELECT array_agg(\(expression) ORDER BY con.conname) + FROM pg_catalog.pg_constraint con + WHERE con.contypid = t.oid AND con.contype = 'c')::text + """ + } + /// The qualified, quoted name of a collation oid, spelled the way a COLLATE clause takes it. private static func collationName(_ oidExpression: String) -> String { """ diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+BulkMetadata.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+BulkMetadata.swift index 6ed075d857..4c309fd9d7 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+BulkMetadata.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+BulkMetadata.swift @@ -21,36 +21,13 @@ extension PostgreSQLPluginDriver { var providesBulkIndexFetch: Bool { true } func fetchAllIndexes(schema: String?) async throws -> [String: [PluginIndexInfo]] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let columnOrdering = versionedCapabilities.hasArrayPosition - ? "ORDER BY array_position(ix.indkey, a.attnum)" - : "ORDER BY a.attnum" - let query = """ - SELECT - t.relname AS table_name, - i.relname AS index_name, - ARRAY_AGG(a.attname \(columnOrdering)) AS columns, - ix.indisunique AS is_unique, - ix.indisprimary AS is_primary, - am.amname AS index_type, - pg_get_expr(ix.indpred, ix.indrelid) AS predicate - FROM pg_index ix - JOIN pg_class i ON i.oid = ix.indexrelid - JOIN pg_class t ON t.oid = ix.indrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - JOIN pg_am am ON am.oid = i.relam - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) - WHERE n.nspname = '\(schemaLiteral)' - GROUP BY t.relname, i.relname, ix.indisunique, ix.indisprimary, am.amname, ix.indpred, ix.indrelid - ORDER BY t.relname, ix.indisprimary DESC, i.relname - """ + let query = PostgreSQLIndexQueries.indexList(schema: schema ?? core.currentSchema, table: nil) let result = try await execute(query: query) var indexes: [String: [PluginIndexInfo]] = [:] for row in result.rows { - guard row.count >= 6, let table = row[0].asText, - let index = PostgreSQLIndexRow.index(from: row) else { continue } - indexes[table, default: []].append(index) + guard let decoded = PostgreSQLIndexRow.index(from: row) else { continue } + indexes[decoded.table, default: []].append(decoded.index) } return indexes } @@ -58,7 +35,7 @@ extension PostgreSQLPluginDriver { var providesBulkTableMetadataFetch: Bool { true } func fetchAllTableMetadata(schema: String?) async throws -> [String: PluginTableMetadata] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema) let query = """ SELECT c.relname AS table_name, @@ -69,7 +46,7 @@ extension PostgreSQLPluginDriver { obj_description(c.oid, 'pg_class') AS comment FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '\(schemaLiteral)' AND c.relkind IN ('r', 'p', 'm', 'f') + WHERE n.nspname = \(schemaLiteral) AND c.relkind IN ('r', 'p', 'm', 'f') ORDER BY c.relname """ let result = try await execute(query: query) @@ -91,24 +68,3 @@ extension PostgreSQLPluginDriver { return metadata } } - -enum PostgreSQLIndexRow { - /// The shared shaping for a `pg_index` row, so the per-table and whole-schema reads cannot - /// disagree about how an index is named, ordered or typed. The row's first field is the table - /// name in the bulk form and the index name in the per-table form, so the offset is passed in. - static func index(from row: [PluginCellValue], offset: Int = 1) -> PluginIndexInfo? { - guard let name = row[safe: offset]?.asText, - let columnsText = row[safe: offset + 1]?.asText else { return nil } - let columns = columnsText - .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) - .components(separatedBy: ",") - return PluginIndexInfo( - name: name, - columns: columns, - isUnique: row[safe: offset + 2]?.asText == "t", - isPrimary: row[safe: offset + 3]?.asText == "t", - type: row[safe: offset + 4]?.asText?.uppercased() ?? "BTREE", - whereClause: row[safe: offset + 5]?.asText - ) - } -} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift index 399a4b5b47..f3ded63fd9 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift @@ -47,13 +47,12 @@ extension PostgreSQLPluginDriver { var statements: [String] = [] statements.append("ALTER TABLE \(qualified) RENAME TO \(quoteIdentifier("\(table)_tablepro_reorder"))") statements.append("CREATE TABLE \(qualified) (\n " + body.joined(separator: ",\n ") + "\n)") - /// `OVERRIDING SYSTEM VALUE` unconditionally. A `GENERATED ALWAYS AS IDENTITY` column - /// refuses a written value without it and takes the whole rebuild down; measured, the - /// clause is accepted and does nothing on a `BY DEFAULT` identity and on a table that has - /// no identity column at all. - statements.append(""" - INSERT INTO \(qualified) (\(copyList)) OVERRIDING SYSTEM VALUE SELECT \(copyList) FROM \(staging) - """) + statements.append(PostgreSQLVersionedStatements.copyRows( + into: qualified, + from: staging, + columnList: copyList, + capabilities: versionedCapabilities + )) statements.append(contentsOf: parts.identityResets(qualified: qualified, quote: quoteIdentifier)) statements.append(contentsOf: parts.inboundForeignKeyDrops) /// A `serial` column's default still calls the sequence the staging table owns, so `DROP @@ -119,28 +118,28 @@ extension PostgreSQLPluginDriver { /// A new identity column starts its sequence at one, so it is wound forward to the rows the /// copy just wrote. Without this the next insert collides with an existing key. /// - /// Both arguments are escaped. A schema or table name may legally contain an apostrophe, - /// and it lands inside a single-quoted literal here. + /// `qualified` is already a quoted identifier pair, and `pg_get_serial_sequence` takes the + /// whole pair as one literal, so it is composed first and quoted once. A schema, table or + /// column name may legally contain an apostrophe or a backslash, and both land inside a + /// literal here. func identityResets(qualified: String, quote: (String) -> String) -> [String] { - identityColumns.map { column in - """ + let relationLiteral = PostgreSQLObjectQueries.quoteLiteral(qualified) + return identityColumns.map { column in + let columnLiteral = PostgreSQLObjectQueries.quoteLiteral(column) + return """ SELECT setval( - pg_get_serial_sequence('\(literal(qualified))', '\(literal(column))'), + pg_get_serial_sequence(\(relationLiteral), \(columnLiteral)), GREATEST(COALESCE((SELECT MAX(\(quote(column))) FROM \(qualified)), 0), 1), true ) """ } } - - private func literal(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "''") - } } private func fetchRebuildParts(table: String, schema: String) async throws -> RebuildParts { - let safeTable = escapeLiteral(table) - let safeSchema = escapeLiteral(schema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) let caps = versionedCapabilities var parts = RebuildParts() @@ -183,7 +182,7 @@ extension PostgreSQLPluginDriver { JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum """).rows @@ -192,9 +191,9 @@ extension PostgreSQLPluginDriver { guard let name = row[safe: 0]?.asText, let definition = row[safe: 1]?.asText else { continue } parts.columnNames.append(name) parts.columnDefinitions[name] = definition - if isTrue(row[safe: 2]?.asText) { parts.identityColumns.append(name) } + if PostgreSQLCatalogBoolean.isTrue(row[safe: 2]?.asText) { parts.identityColumns.append(name) } /// A generated column is computed, never written, so `INSERT` refuses it by name. - if !isTrue(row[safe: 3]?.asText) { parts.copyableColumns.append(name) } + if !PostgreSQLCatalogBoolean.isTrue(row[safe: 3]?.asText) { parts.copyableColumns.append(name) } } /// Named, and added after the staging table is gone. Declared inline instead, PostgreSQL @@ -204,7 +203,7 @@ extension PostgreSQLPluginDriver { FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND con.contype IN ('p', 'u', 'c', 'x') ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 ELSE 2 END, con.conname """) @@ -214,7 +213,7 @@ extension PostgreSQLPluginDriver { FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' AND con.contype = 'f' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND con.contype = 'f' ORDER BY con.conname """) @@ -227,7 +226,7 @@ extension PostgreSQLPluginDriver { JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_class c2 ON c2.oid = con.conrelid JOIN pg_namespace n2 ON n2.oid = c2.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' AND con.contype = 'f' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND con.contype = 'f' AND con.conrelid <> con.confrelid ORDER BY con.conname """ @@ -246,12 +245,12 @@ extension PostgreSQLPluginDriver { /// fail on a duplicate name. parts.indexes = try await textRows(""" SELECT indexdef FROM pg_indexes - WHERE tablename = '\(safeTable)' AND schemaname = '\(safeSchema)' + WHERE tablename = \(tableLiteral) AND schemaname = \(schemaLiteral) AND indexname NOT IN ( SELECT con.conname FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) ) ORDER BY indexname """) @@ -261,7 +260,7 @@ extension PostgreSQLPluginDriver { FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' AND NOT t.tgisinternal + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND NOT t.tgisinternal ORDER BY t.tgname """) @@ -280,7 +279,7 @@ extension PostgreSQLPluginDriver { FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' AND NOT t.tgisinternal + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND NOT t.tgisinternal AND t.tgenabled <> 'O' ORDER BY t.tgname """) @@ -297,7 +296,7 @@ extension PostgreSQLPluginDriver { FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped AND \(caps.hasIdentityColumns ? "a.attidentity = ''" : "true") AND pg_get_serial_sequence( @@ -311,7 +310,7 @@ extension PostgreSQLPluginDriver { || ' IS ' || quote_literal(obj_description(c.oid, 'pg_class')) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND obj_description(c.oid, 'pg_class') IS NOT NULL UNION ALL SELECT 'COMMENT ON COLUMN ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname) @@ -320,7 +319,7 @@ extension PostgreSQLPluginDriver { FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped AND col_description(c.oid, a.attnum) IS NOT NULL """) @@ -333,7 +332,7 @@ extension PostgreSQLPluginDriver { JOIN pg_namespace dn ON dn.oid = dc.relnamespace JOIN pg_class c ON c.oid = d.refobjid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' + WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND dc.relkind IN ('v', 'm') AND dc.oid <> c.oid ORDER BY 1 @@ -345,11 +344,4 @@ extension PostgreSQLPluginDriver { private func textRows(_ query: String) async throws -> [String] { try await execute(query: query).rows.compactMap { $0[safe: 0]?.asText } } - - /// libpq reports a boolean as `t` on the text protocol and the driver may hand it back either - /// way, so both spellings are accepted rather than one being assumed. - private func isTrue(_ value: String?) -> Bool { - guard let value else { return false } - return value == "t" || value.lowercased() == "true" - } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift index bd1e87d7b1..ba85aae076 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift @@ -8,17 +8,12 @@ import TableProPluginKit extension PostgreSQLPluginDriver { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { - let safeSchema = escapeStringLiteral(schema ?? core.currentSchema) - let safeTable = escapeStringLiteral(table) let catalog = try await fetchTypeCatalog() - let projections = columnProjections() let query = PostgreSQLSchemaQueries.columnsQuery( - schemaLiteral: safeSchema, - tableLiteral: safeTable, - identityProjection: projections.identity, - generatedProjection: projections.generated, - generationExpressionProjection: projections.generationExpression, - attributeJoin: projections.attributeJoin + schema: schema ?? core.currentSchema, + table: table, + capabilities: versionedCapabilities, + includeMaterializedViews: includesMaterializedViews() ) let result = try await execute(query: query) return result.rows.compactMap { row in @@ -27,16 +22,12 @@ extension PostgreSQLPluginDriver { } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { - let safeSchema = escapeStringLiteral(schema ?? core.currentSchema) let catalog = try await fetchTypeCatalog() - let projections = columnProjections() let query = PostgreSQLSchemaQueries.columnsQuery( - schemaLiteral: safeSchema, - tableLiteral: nil, - identityProjection: projections.identity, - generatedProjection: projections.generated, - generationExpressionProjection: projections.generationExpression, - attributeJoin: projections.attributeJoin + schema: schema ?? core.currentSchema, + table: nil, + capabilities: versionedCapabilities, + includeMaterializedViews: includesMaterializedViews() ) let result = try await execute(query: query) var allColumns: [String: [PluginColumnInfo]] = [:] @@ -51,42 +42,22 @@ extension PostgreSQLPluginDriver { func fetchCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] { let query = PostgreSQLSchemaQueries.checkConstraintsQuery( - schemaLiteral: escapeStringLiteral(schema ?? core.currentSchema), - tableLiteral: escapeStringLiteral(table) + schema: schema ?? core.currentSchema, + table: table ) let result = try await execute(query: query) return result.rows.compactMap { row in guard let name = row[safe: 0]?.asText, let definition = row[safe: 1]?.asText else { return nil } - // JSON rather than a comma-joined string: a quoted PostgreSQL identifier may itself - // contain a comma, which splitting would turn into two column names. - let columns = (row[safe: 3]?.asText?.nilIfEmpty) - .flatMap { $0.data(using: .utf8) } - .flatMap { try? JSONDecoder().decode([String].self, from: $0) } ?? [] return PluginCheckConstraintInfo( name: name, expression: PostgreSQLCheckConstraintDefinition.expression(fromConstraintDef: definition), - columns: columns, - isValidated: row[safe: 2]?.asText?.lowercased() != "f" + columns: PostgreSQLTextArray.values(row[safe: 3]?.asText), + isValidated: PostgreSQLCatalogBoolean.isTrue(row[safe: 2]?.asText) ) } } - private func columnProjections() - -> (identity: String, generated: String, generationExpression: String, attributeJoin: String) { - let caps = versionedCapabilities - let identity = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" - let generated = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" - let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ - LEFT JOIN pg_catalog.pg_attribute a - ON a.attrelid = st.relid - AND a.attname = c.column_name - AND NOT a.attisdropped - """ : "" - let generationExpression = caps.hasGeneratedColumns ? "c.generation_expression" : "NULL::text" - return (identity, generated, generationExpression, attributeJoin) - } - fileprivate func fetchEnumLabelMap() async throws -> [String: [String]] { let result = try await execute(query: PostgreSQLSchemaQueries.enumLabelQuery) var map: [String: [String]] = [:] diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Comments.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Comments.swift new file mode 100644 index 0000000000..60608d2cb5 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Comments.swift @@ -0,0 +1,25 @@ +// +// PostgreSQLPluginDriver+Comments.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension PostgreSQLPluginDriver { + /// A plain read rather than the `search_path`-emptied one `fetchViewDefinition` takes: nothing + /// here is deparsed by the server, so the session's path cannot change the answer. + /// + /// Cockroach, Redshift and PGlite inherit this, which is correct: all three keep + /// `obj_description` and `col_description`. + func fetchCommentDDL(table: String, schema: String?) async throws -> [String] { + let resolvedSchema = schema ?? core.currentSchema + let query = PostgreSQLCommentStatements.catalogQuery(name: table, schema: resolvedSchema) + let result = try await execute(query: query) + return PostgreSQLCommentStatements.statements( + name: table, + schema: resolvedSchema, + rows: result.rows.map { $0.map(\.asText) } + ) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+PrincipalSQL.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+PrincipalSQL.swift index 1d7654d415..1f05e90f7f 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+PrincipalSQL.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+PrincipalSQL.swift @@ -13,7 +13,7 @@ extension PostgreSQLPluginDriver { options.append(contentsOf: attributeKeywords(definition.attributes)) if let password = definition.password, !password.isEmpty { - options.append("PASSWORD '\(escapeStringLiteral(password))'") + options.append("PASSWORD \(PostgreSQLObjectQueries.quoteLiteral(password))") } if let limit = definition.connectionLimit { options.append("CONNECTION LIMIT \(limit)") @@ -24,7 +24,7 @@ extension PostgreSQLPluginDriver { "GRANT \(quoteIdentifier($0)) TO \(role)" }) if let comment = definition.comment, !comment.isEmpty { - statements.append("COMMENT ON ROLE \(role) IS '\(escapeStringLiteral(comment))'") + statements.append("COMMENT ON ROLE \(role) IS \(PostgreSQLRelationSQL.commentValue(comment))") } return statements } @@ -51,9 +51,7 @@ extension PostgreSQLPluginDriver { statements.append(contentsOf: membershipStatements(old: old, new: new, role: role)) if old.comment != new.comment { - let comment = new.comment ?? "" - let value = comment.isEmpty ? "NULL" : "'\(escapeStringLiteral(comment))'" - statements.append("COMMENT ON ROLE \(role) IS \(value)") + statements.append("COMMENT ON ROLE \(role) IS \(PostgreSQLRelationSQL.commentValue(new.comment))") } if old.ref.name != new.ref.name { statements.append("ALTER ROLE \(role) RENAME TO \(quoteIdentifier(new.ref.name))") @@ -63,7 +61,7 @@ extension PostgreSQLPluginDriver { func generateSetPasswordSQL(principal: PluginPrincipalRef, password: String) -> [String]? { let role = quoteIdentifier(principal.name) - return ["ALTER ROLE \(role) WITH PASSWORD '\(escapeStringLiteral(password))'"] + return ["ALTER ROLE \(role) WITH PASSWORD \(PostgreSQLObjectQueries.quoteLiteral(password))"] } func generateDropPrincipalSQL( @@ -119,9 +117,16 @@ extension PostgreSQLPluginDriver { } } + private var supportedRoleAttributes: Set { + PostgreSQLVersionedStatements.roleAttributes(capabilities: versionedCapabilities) + } + private func attributeKeywords(_ attributes: [PluginPrincipalAttribute]) -> [String] { - attributes.compactMap { attribute in - guard let known = PostgreSQLRoleAttribute(rawValue: attribute.key) else { return nil } + let supported = supportedRoleAttributes + return attributes.compactMap { attribute in + guard let known = PostgreSQLRoleAttribute(rawValue: attribute.key), supported.contains(known) else { + return nil + } return known.keyword(isEnabled: attribute.isEnabled) } } @@ -131,8 +136,11 @@ extension PostgreSQLPluginDriver { new: [PluginPrincipalAttribute] ) -> [String] { let oldByKey = Dictionary(uniqueKeysWithValues: old.map { ($0.key, $0.isEnabled) }) + let supported = supportedRoleAttributes return new.compactMap { attribute in - guard let known = PostgreSQLRoleAttribute(rawValue: attribute.key) else { return nil } + guard let known = PostgreSQLRoleAttribute(rawValue: attribute.key), supported.contains(known) else { + return nil + } guard oldByKey[attribute.key] != attribute.isEnabled else { return nil } return known.keyword(isEnabled: attribute.isEnabled) } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Principals.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Principals.swift index bc7c32c1ca..fec2c9458d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Principals.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Principals.swift @@ -28,7 +28,7 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { ) async throws -> [PluginPrivilegeScope] { let database = try await currentDatabaseName() let sql = PostgreSQLPrincipalQueries.searchObjects( - patternLiteral: escapeStringLiteral(query), + pattern: query, limit: limit ) let result = try await execute(query: sql) @@ -42,6 +42,7 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { func fetchPrincipals() async throws -> [PluginPrincipalInfo] { let memberships = try await fetchMemberships() + let supportedAttributes = PostgreSQLVersionedStatements.roleAttributes(capabilities: versionedCapabilities) let query = PostgreSQLPrincipalQueries.principals( includeBypassRLS: versionedCapabilities.hasBypassRLS ) @@ -49,8 +50,8 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { return result.rows.compactMap { row -> PluginPrincipalInfo? in guard let name = row[safe: 0]?.asText else { return nil } - let canLogin = Self.decodeBoolean(row[safe: 1]?.asText) - let attributes = Self.decodeAttributes(row: row) + let canLogin = PostgreSQLCatalogBoolean.isTrue(row[safe: 1]?.asText) + let attributes = Self.decodeAttributes(row: row, supported: supportedAttributes) let connectionLimit = row[safe: 8]?.asText.flatMap(Int.init) return PluginPrincipalInfo( @@ -77,13 +78,13 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { } func fetchGrants(for principal: PluginPrincipalRef) async throws -> [PluginGrantInfo] { - let roleLiteral = escapeStringLiteral(principal.name) + let role = principal.name let database = try await currentDatabaseName() - let databaseGrants = try await fetchDatabaseGrants(roleLiteral: roleLiteral) - let schemaGrants = try await fetchSchemaGrants(roleLiteral: roleLiteral, database: database) - let tableGrants = try await fetchTableGrants(roleLiteral: roleLiteral, database: database) - let columnGrants = try await fetchColumnGrants(roleLiteral: roleLiteral, database: database) + let databaseGrants = try await fetchDatabaseGrants(role: role) + let schemaGrants = try await fetchSchemaGrants(role: role, database: database) + let tableGrants = try await fetchTableGrants(role: role, database: database) + let columnGrants = try await fetchColumnGrants(role: role, database: database) return databaseGrants + schemaGrants + tableGrants + columnGrants } @@ -117,7 +118,7 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { } private func tables(in database: String, schema: String) async throws -> [PluginPrivilegeScope] { - let query = PostgreSQLPrincipalQueries.tables(schemaLiteral: escapeStringLiteral(schema)) + let query = PostgreSQLPrincipalQueries.tables(schema: schema) let result = try await execute(query: query) return result.rows.compactMap { row in guard let table = row[safe: 0]?.asText else { return nil } @@ -131,8 +132,8 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { table: String ) async throws -> [PluginPrivilegeScope] { let query = PostgreSQLPrincipalQueries.columns( - schemaLiteral: escapeStringLiteral(schema), - tableLiteral: escapeStringLiteral(table) + schema: schema, + table: table ) let result = try await execute(query: query) return result.rows.compactMap { row in @@ -141,8 +142,8 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { } } - private func fetchColumnGrants(roleLiteral: String, database: String) async throws -> [PluginGrantInfo] { - let query = PostgreSQLPrincipalQueries.columnGrants(roleLiteral: roleLiteral) + private func fetchColumnGrants(role: String, database: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.columnGrants(role: role) let result = try await execute(query: query) return result.rows.compactMap { row -> PluginGrantInfo? in guard let schema = row[safe: 0]?.asText, @@ -152,7 +153,7 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { return PluginGrantInfo( privilege: privilege, scope: .column(database: database, schema: schema, table: table, column: column), - isGrantable: Self.decodeBoolean(row[safe: 4]?.asText) + isGrantable: PostgreSQLCatalogBoolean.isTrue(row[safe: 4]?.asText) ) } } @@ -165,10 +166,10 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { func principalOwnsObjects(_ principal: PluginPrincipalRef) async throws -> Bool { let query = PostgreSQLPrincipalQueries.ownsObjects( - roleLiteral: escapeStringLiteral(principal.name) + role: principal.name ) let result = try await execute(query: query) - return Self.decodeBoolean(result.rows.first?[safe: 0]?.asText) + return PostgreSQLCatalogBoolean.isTrue(result.rows.first?[safe: 0]?.asText) } private func fetchMemberships() async throws -> [String: [String]] { @@ -187,8 +188,8 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { return result.rows.first?[safe: 0]?.asText ?? "" } - private func fetchDatabaseGrants(roleLiteral: String) async throws -> [PluginGrantInfo] { - let query = PostgreSQLPrincipalQueries.databaseGrants(roleLiteral: roleLiteral) + private func fetchDatabaseGrants(role: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.databaseGrants(role: role) let result = try await execute(query: query) return result.rows.compactMap { row -> PluginGrantInfo? in guard let database = row[safe: 0]?.asText, @@ -196,13 +197,13 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { return PluginGrantInfo( privilege: privilege, scope: .database(database), - isGrantable: Self.decodeBoolean(row[safe: 2]?.asText) + isGrantable: PostgreSQLCatalogBoolean.isTrue(row[safe: 2]?.asText) ) } } - private func fetchSchemaGrants(roleLiteral: String, database: String) async throws -> [PluginGrantInfo] { - let query = PostgreSQLPrincipalQueries.schemaGrants(roleLiteral: roleLiteral) + private func fetchSchemaGrants(role: String, database: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.schemaGrants(role: role) let result = try await execute(query: query) return result.rows.compactMap { row -> PluginGrantInfo? in guard let schema = row[safe: 0]?.asText, @@ -210,13 +211,13 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { return PluginGrantInfo( privilege: privilege, scope: .schema(database: database, schema: schema), - isGrantable: Self.decodeBoolean(row[safe: 2]?.asText) + isGrantable: PostgreSQLCatalogBoolean.isTrue(row[safe: 2]?.asText) ) } } - private func fetchTableGrants(roleLiteral: String, database: String) async throws -> [PluginGrantInfo] { - let query = PostgreSQLPrincipalQueries.tableGrants(roleLiteral: roleLiteral) + private func fetchTableGrants(role: String, database: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.tableGrants(role: role) let result = try await execute(query: query) return result.rows.compactMap { row -> PluginGrantInfo? in guard let schema = row[safe: 0]?.asText, @@ -225,12 +226,15 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { return PluginGrantInfo( privilege: privilege, scope: .table(database: database, schema: schema, table: table), - isGrantable: Self.decodeBoolean(row[safe: 3]?.asText) + isGrantable: PostgreSQLCatalogBoolean.isTrue(row[safe: 3]?.asText) ) } } - private static func decodeAttributes(row: [PluginCellValue]) -> [PluginPrincipalAttribute] { + private static func decodeAttributes( + row: [PluginCellValue], + supported: Set + ) -> [PluginPrincipalAttribute] { let columnOffsets: [(PostgreSQLRoleAttribute, Int)] = [ (.superuser, 2), (.createdb, 3), @@ -239,17 +243,12 @@ extension PostgreSQLPluginDriver: PluginPrincipalManagement { (.bypassrls, 6), (.inherit, 7) ] - return columnOffsets.map { attribute, offset in + return columnOffsets.filter { supported.contains($0.0) }.map { attribute, offset in PluginPrincipalAttribute( key: attribute.rawValue, label: attribute.label, - isEnabled: decodeBoolean(row[safe: offset]?.asText) + isEnabled: PostgreSQLCatalogBoolean.isTrue(row[safe: offset]?.asText) ) } } - - private static func decodeBoolean(_ value: String?) -> Bool { - guard let value else { return false } - return value == "t" || value == "true" || value == "1" - } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift index d2d49a526a..991e30a951 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift @@ -11,7 +11,7 @@ extension PostgreSQLPluginDriver { let resolvedSchema = schema ?? currentSchema ?? "public" let query = PostgreSQLObjectQueries.routineList( schema: resolvedSchema, - serverVersionNumber: serverVersionNumber + capabilities: catalogCapabilities ) let result = try await execute(query: query) return result.rows.compactMap { row -> PluginRoutineInfo? in @@ -89,7 +89,7 @@ extension PostgreSQLPluginDriver { orientation: row[safe: 5]?.asText, statement: definition, definition: definition, - enabled: row[safe: 6]?.asText == "t", + enabled: PostgreSQLCatalogBoolean.isTrue(row[safe: 6]?.asText), attributes: attributes ) } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Sequences.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Sequences.swift new file mode 100644 index 0000000000..9a0c428aa9 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Sequences.swift @@ -0,0 +1,54 @@ +// +// PostgreSQLPluginDriver+Sequences.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import os +import TableProPluginKit + +extension PostgreSQLPluginDriver { + static let sequenceLogger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "Sequences") + + func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { + let definitions = try await sequenceDefinitions(schema: schema ?? core.currentSchema, dependentOnTable: table) + return definitions.map { (name: $0.name, ddl: $0.ddl) } + } + + func fetchSequences(schema: String?) async throws -> [PluginSequenceInfo] { + let schemaName = schema ?? core.currentSchema + let definitions = try await sequenceDefinitions(schema: schemaName, dependentOnTable: nil) + return definitions.map { PluginSequenceInfo(name: $0.name, ddl: $0.ddl, schema: schemaName) } + } + + private func sequenceDefinitions( + schema: String, + dependentOnTable table: String? + ) async throws -> [PostgreSQLSequenceDefinition] { + let source = PostgreSQLSequenceQueries.source(hasSequencesCatalog: includesSequencesCatalog()) + let listing = try await execute( + query: PostgreSQLSequenceQueries.sequenceList(schema: schema, dependentOnTable: table, source: source) + ) + var definitions: [PostgreSQLSequenceDefinition] = [] + for definition in PostgreSQLSequenceQueries.definitions(from: listing.rows) { + guard definition.needsLastValueRead else { + definitions.append(definition) + continue + } + definitions.append(definition.withLastValue(await lastValue(schema: schema, sequence: definition.name))) + } + return definitions + } + + private func lastValue(schema: String, sequence: String) async -> String? { + do { + let query = PostgreSQLSequenceQueries.lastValue(schema: schema, sequence: sequence) + return try await execute(query: query).rows.first?[safe: 0]?.asText + } catch { + Self.sequenceLogger.debug( + "Sequence last value unavailable for \(sequence, privacy: .public): \(error.localizedDescription)" + ) + return nil + } + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ServerSupport.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ServerSupport.swift new file mode 100644 index 0000000000..8f66c2fe66 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ServerSupport.swift @@ -0,0 +1,42 @@ +// +// PostgreSQLPluginDriver+ServerSupport.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import os +import TableProPluginKit + +internal extension PostgreSQLPluginDriver { + private static let sessionLogger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "PostgreSQLSession") + + var connectedDatabase: String? { + sessionFacts.withLock { $0.database } + } + + var unsupportedStructureColumnFields: Set { + PostgreSQLVersionedStatements.unsupportedStructureColumnFields(capabilities: versionedCapabilities) + } + + var unsupportedIndexTypes: Set { + PostgreSQLVersionedStatements.unsupportedIndexTypes(capabilities: versionedCapabilities) + } + + func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { + PostgreSQLVersionedStatements.refusal(for: operation, capabilities: versionedCapabilities) + } + + func probeSessionFacts() async { + do { + let result = try await core.execute(query: PostgreSQLSessionFacts.probeQuery) + let row = result.rows.first?.map(\.asText) ?? [] + let facts = PostgreSQLSessionFacts(probeRow: row) + sessionFacts.withLock { $0 = facts } + } catch { + sessionFacts.withLock { $0 = .unknown } + Self.sessionLogger.error( + "Session probe failed; DDL falls back to forms every server accepts: \(error.localizedDescription, privacy: .public)" + ) + } + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+TriggerEditing.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+TriggerEditing.swift new file mode 100644 index 0000000000..6e8be86845 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+TriggerEditing.swift @@ -0,0 +1,55 @@ +// +// PostgreSQLPluginDriver+TriggerEditing.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +internal extension PostgreSQLPluginDriver { + var triggerEditUsesReplace: Bool { versionedCapabilities.hasCreateOrReplaceTrigger } + + var supportsTransactionalDDL: Bool { true } + + func qualifiedTable(_ table: String, schema: String?) -> String { + let resolved = schema ?? core.currentSchema + return "\(quoteIdentifier(resolved)).\(quoteIdentifier(table))" + } + + func createTriggerTemplate(table: String, schema: String?) -> String? { + PostgreSQLVersionedStatements.triggerTemplate( + qualifiedTable: qualifiedTable(table, schema: schema), + qualifiedFunction: qualifiedTable("trigger_function", schema: schema), + capabilities: versionedCapabilities + ) + } + + func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { + let resolvedSchema = schema ?? core.currentSchema + let query = """ + SELECT pg_get_functiondef(t.tgfoid), pg_get_triggerdef(t.oid) + FROM pg_catalog.pg_trigger t + JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE t.tgname = \(PostgreSQLObjectQueries.quoteLiteral(name)) + AND c.relname = \(PostgreSQLObjectQueries.quoteLiteral(table)) + AND n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(resolvedSchema)) + AND NOT t.tgisinternal + LIMIT 1 + """ + let result = try await execute(query: query) + guard let row = result.rows.first, row.count >= 2, + let functionDef = row[0].asText, + let triggerDef = row[1].asText else { return nil } + return PostgreSQLVersionedStatements.editableTriggerDefinition( + functionDefinition: functionDef, + triggerDefinition: triggerDef, + dropStatement: generateDropTriggerSQL(name: name, table: table, schema: schema), + capabilities: versionedCapabilities + ) + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + "DROP TRIGGER IF EXISTS \(quoteIdentifier(name)) ON \(qualifiedTable(table, schema: schema))" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift index 121ed60f43..792316dac9 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift @@ -12,7 +12,7 @@ extension PostgreSQLPluginDriver { let query = PostgreSQLObjectQueries.userDefinedTypeList( schema: resolvedSchema, identity: nil, - serverVersionNumber: serverVersionNumber + capabilities: catalogCapabilities ) let result = try await execute(query: query) return result.rows @@ -30,7 +30,7 @@ extension PostgreSQLPluginDriver { let query = PostgreSQLObjectQueries.userDefinedTypeList( schema: nil, identity: String(oid), - serverVersionNumber: serverVersionNumber + capabilities: catalogCapabilities ) let result = try await execute(query: query) let match = result.rows @@ -43,7 +43,7 @@ extension PostgreSQLPluginDriver { let query = PostgreSQLObjectQueries.userDefinedTypeList( schema: resolvedSchema, identity: nil, - serverVersionNumber: serverVersionNumber + capabilities: catalogCapabilities ) let result = try await execute(query: query) let match = result.rows diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift new file mode 100644 index 0000000000..dc9f8cc24d --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift @@ -0,0 +1,91 @@ +// +// PostgreSQLPluginDriver+Views.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension PostgreSQLPluginDriver { + /// `pg_views` and `pg_matviews` return the query alone, so the definition this used to build + /// from them lost the view's options and check option, and read the body under the connection's + /// own search path. The statement is now rebuilt from the catalog with every name qualified. + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + let resolvedSchema = schema ?? core.currentSchema + let query = PostgreSQLViewDefinition.catalogQuery(name: view, schema: resolvedSchema) + let result = try await executeQualifiedRead(query) + guard let row = result.rows.first, + let catalogRow = PostgreSQLViewDefinition.parse(row: row.map(\.asText)) + else { + throw LibPQPluginError(message: "Failed to fetch definition for view '\(view)'", sqlState: nil, detail: nil) + } + return PostgreSQLViewDefinition.statement(name: view, schema: resolvedSchema, row: catalogRow) + } + + func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { + PostgreSQLRelationSQL.commentStatement( + name: name, + schema: schema ?? core.currentSchema, + objectType: objectType, + comment: comment + ) + } + + func refreshMaterializedViewStatement(name: String, schema: String?, concurrently: Bool) -> String? { + PostgreSQLRelationSQL.refreshStatement( + name: name, + schema: schema ?? core.currentSchema, + concurrently: concurrently + ) + } + + func concurrentRefreshAvailability( + materializedView: String, + schema: String? + ) async throws -> PluginConcurrentRefreshAvailability? { + let query = PostgreSQLRelationSQL.concurrentRefreshQuery( + name: materializedView, + schema: schema ?? core.currentSchema + ) + let result = try await execute(query: query) + guard let row = result.rows.first, row.count >= 2 else { + throw LibPQPluginError( + message: "Materialized view '\(materializedView)' was not found", + sqlState: nil, + detail: nil + ) + } + return PostgreSQLRelationSQL.concurrentRefreshAvailability( + isPopulated: row[0].asText == "1", + hasUsableUniqueIndex: row[1].asText == "1" + ) + } + + /// Reads with `search_path` emptied so every name the server deparses comes back qualified. + /// + /// A pooled connection is never inside a transaction, and there the prefix and the read form + /// one implicit transaction that restores the path as it ends. PGlite has no pool, so its reads + /// share the connection a query tab may have left inside `BEGIN`; the prefix would then outlive + /// the read and every later unqualified name in that tab would fail. A savepoint scopes it there. + private func executeQualifiedRead(_ query: String) async throws -> PluginQueryResult { + let statement = PostgreSQLViewDefinition.qualifiedReadPrefix + query + guard core.isInsideTransactionBlock else { + return try await execute(query: statement) + } + let savepoint = "tablepro_qualified_read" + _ = try await execute(query: "SAVEPOINT \(savepoint)") + /// The rollback is best effort on both paths. It undoes a `SET LOCAL` in a transaction that + /// is about to end anyway, so a connection lost between the read and the rollback has taken + /// the whole transaction with it, and reporting that instead of the definition just read + /// would lose the answer to a failure that no longer matters. + let release = "ROLLBACK TO SAVEPOINT \(savepoint); RELEASE SAVEPOINT \(savepoint)" + do { + let result = try await execute(query: statement) + _ = try? await execute(query: release) + return result + } catch { + _ = try? await execute(query: release) + throw error + } + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 937d58b4e7..4a1ba2a73d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -19,10 +19,18 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { private static let undefinedFunctionSQLState = PostgreSQLTableListingLadder.undefinedFunctionSQLState private var catalogPresence: PostgreSQLCatalogPresence? + let sessionFacts = OSAllocatedUnfairLock(initialState: PostgreSQLSessionFacts.unknown) + + var serverVersionNumber: Int32 { + let reported = core.serverVersionNumber + return sessionFacts.withLock { $0.resolvedServerVersion(reported: reported) } + } - var serverVersionNumber: Int32 { core.serverVersionNumber } var versionedCapabilities: PostgreSQLCapabilities { - PostgreSQLCapabilities(serverVersion: core.serverVersionNumber) + PostgreSQLCapabilities(serverVersion: serverVersionNumber) + } + var catalogCapabilities: PostgreSQLCapabilities { + .assumingModernWhenUnknown(core.serverVersionNumber) } var capabilities: PluginCapabilities { @@ -51,6 +59,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func connect() async throws { core.onPostConnect = { [weak self] in + await self?.probeSessionFacts() await self?.probeCatalogPresence() await self?.probePostgisOids() await self?.probeEnumOids() @@ -71,13 +80,14 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { private func probePostgisOids() async { do { let result = try await core.execute(query: PostGISSpatialRewrite.probeQuery) - var map: [UInt32: String] = [:] + var map: [UInt32: PostGISType] = [:] for row in result.rows { - guard row.count >= 2, + guard row.count >= 3, let oidText = row[0].asText, let oid = UInt32(oidText), - let typname = row[1].asText else { continue } - map[oid] = typname + let typname = row[1].asText, + let schema = row[2].asText else { continue } + map[oid] = PostGISType(name: typname, schema: schema) } core.setPostgisOidMap(map) } catch { @@ -85,7 +95,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } } - private func includesMaterializedViews() -> Bool { + func includesMaterializedViews() -> Bool { catalogPresence?.hasMaterializedViews ?? versionedCapabilities.hasMaterializedViewsCatalog } @@ -93,7 +103,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { catalogPresence?.hasForeignTables ?? versionedCapabilities.hasForeignTablesCatalog } - private func includesSequencesCatalog() -> Bool { + func includesSequencesCatalog() -> Bool { catalogPresence?.hasSequences ?? versionedCapabilities.hasSequencesCatalog } @@ -116,34 +126,28 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { /// A duplicated database arrives with `public` alone, so every other schema its tables are /// qualified with has to be made before the first `CREATE TABLE` names one. func createSchemaStatement(name: String) -> String? { - "CREATE SCHEMA IF NOT EXISTS \(quoteIdentifier(name))" + PostgreSQLVersionedStatements.createSchema(name, capabilities: versionedCapabilities) } // MARK: - Maintenance func supportedMaintenanceOperations() -> [String]? { - ["VACUUM", "ANALYZE", "REINDEX", "CLUSTER"] + PostgreSQLMaintenance.operations.map(\.name) + } + + func maintenanceOperations() -> [PluginMaintenanceOperation]? { + PostgreSQLMaintenance.operations } func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { - let target = table.map { quoteIdentifier($0) } - switch operation { - case "VACUUM": - var opts: [String] = [] - if options["full"] == "true" { opts.append("FULL") } - if options["analyze"] == "true" { opts.append("ANALYZE") } - if options["verbose"] == "true" { opts.append("VERBOSE") } - let optClause = opts.isEmpty ? "" : "(\(opts.joined(separator: ", "))) " - return [target.map { "VACUUM \(optClause)\($0)" } ?? "VACUUM"] - case "ANALYZE": - return [target.map { "ANALYZE \($0)" } ?? "ANALYZE"] - case "REINDEX": - return [target.map { "REINDEX TABLE \($0)" } ?? "REINDEX DATABASE CONCURRENTLY"] - case "CLUSTER": - return target.map { ["CLUSTER \($0)"] } - default: - return nil - } + PostgreSQLMaintenance.statements( + operation: operation, + table: table, + schema: schema, + options: options, + connectedDatabase: connectedDatabase, + capabilities: versionedCapabilities + ) } // MARK: - View Templates @@ -164,10 +168,10 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let schemaName = schema ?? core.currentSchema func query(_ attempt: PostgreSQLTableListingAttempt) -> String { PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: schemaLiteral, + schema: schemaName, includeMaterializedViews: attempt.includeOptionalCatalogs && includesMaterializedViews(), includeForeignTables: attempt.includeOptionalCatalogs && includesForeignTables(), includeComments: attempt.includeComments, @@ -206,11 +210,10 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { guard versionedCapabilities.hasDeclarativePartitioning else { return [] } - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let result = try await execute( query: PostgreSQLSchemaQueries.fetchPartitions( - schemaLiteral: schemaLiteral, - tableLiteral: escapeLiteral(table) + schema: schema ?? core.currentSchema, + table: table ) ) return result.rows.compactMap { row -> PluginTableInfo? in @@ -229,90 +232,19 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { /// schemas holding a table of the same name returned each other's indexes merged into one list, /// which a comparison between those two schemas reports as neither side differing. func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let columnOrdering = versionedCapabilities.hasArrayPosition - ? "ORDER BY array_position(ix.indkey, a.attnum)" - : "ORDER BY a.attnum" - let query = """ - SELECT - i.relname AS index_name, - ARRAY_AGG(a.attname \(columnOrdering)) AS columns, - ix.indisunique AS is_unique, - ix.indisprimary AS is_primary, - am.amname AS index_type, - pg_get_expr(ix.indpred, ix.indrelid) AS predicate - FROM pg_index ix - JOIN pg_class i ON i.oid = ix.indexrelid - JOIN pg_class t ON t.oid = ix.indrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - JOIN pg_am am ON am.oid = i.relam - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) - WHERE t.relname = '\(escapeLiteral(table))' AND n.nspname = '\(schemaLiteral)' - GROUP BY i.relname, ix.indisunique, ix.indisprimary, am.amname, ix.indpred, ix.indrelid - ORDER BY ix.indisprimary DESC, i.relname - """ + let query = PostgreSQLIndexQueries.indexList(schema: schema ?? core.currentSchema, table: table) let result = try await execute(query: query) - return result.rows.compactMap { PostgreSQLIndexRow.index(from: $0, offset: 0) } + return result.rows.compactMap { PostgreSQLIndexRow.index(from: $0)?.index } } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let query = """ - SELECT - con.conname, - src_col.attname, - ref_cl.relname AS referenced_table, - ref_col.attname AS referenced_column, - ref_ns.nspname AS referenced_schema, - CASE con.confdeltype - WHEN 'c' THEN 'CASCADE' - WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' - WHEN 'r' THEN 'RESTRICT' - ELSE 'NO ACTION' - END AS delete_rule, - CASE con.confupdtype - WHEN 'c' THEN 'CASCADE' - WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' - WHEN 'r' THEN 'RESTRICT' - ELSE 'NO ACTION' - END AS update_rule - FROM pg_catalog.pg_constraint con - JOIN pg_catalog.pg_class src_cl ON src_cl.oid = con.conrelid - JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace - JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = con.confrelid - JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace - CROSS JOIN LATERAL unnest(con.conkey, con.confkey) - WITH ORDINALITY AS cols(src_attnum, ref_attnum, ord) - JOIN pg_catalog.pg_attribute src_col - ON src_col.attrelid = con.conrelid AND src_col.attnum = cols.src_attnum - JOIN pg_catalog.pg_attribute ref_col - ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum - WHERE con.contype = 'f' - AND src_cl.relname = '\(escapeLiteral(table))' - AND src_ns.nspname = '\(schemaLiteral)' - ORDER BY con.conname, cols.ord - """ + let resolvedSchema = schema ?? core.currentSchema + let query = PostgreSQLForeignKeyQueries.foreignKeyList( + schema: resolvedSchema, table: table, capabilities: catalogCapabilities + ) let result = try await execute(query: query) - let foreignKeys: [PluginForeignKeyInfo] = result.rows.compactMap { row -> PluginForeignKeyInfo? in - guard row.count >= 7, - let name = row[0].asText, - let column = row[1].asText, - let refTable = row[2].asText, - let refColumn = row[3].asText - else { return nil } - return PluginForeignKeyInfo( - name: name, - column: column, - referencedTable: refTable, - referencedColumn: refColumn, - referencedSchema: row[4].asText, - onDelete: row[5].asText ?? "NO ACTION", - onUpdate: row[6].asText ?? "NO ACTION" - ) - } - Self.logger.info("[fk] postgres fetchForeignKeys schema=\(schema ?? self.core.currentSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)") + let foreignKeys = result.rows.compactMap { PostgreSQLForeignKeyRow($0)?.foreignKey } + Self.logger.info("[fk] postgres fetchForeignKeys schema=\(resolvedSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)") return foreignKeys } @@ -329,70 +261,6 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { return triggers } - var triggerEditUsesReplace: Bool { true } - - var supportsTransactionalDDL: Bool { true } - - private func qualifiedTable(_ table: String, schema: String?) -> String { - let resolved = schema ?? core.currentSchema - return "\(quoteIdentifier(resolved)).\(quoteIdentifier(table))" - } - - func createTriggerTemplate(table: String, schema: String?) -> String? { - let qualified = qualifiedTable(table, schema: schema) - let fn = qualifiedTable("trigger_function", schema: schema) - return """ - CREATE OR REPLACE FUNCTION \(fn)() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - BEGIN - -- NEW.updated_at := now(); - RETURN NEW; - END; - $function$; - - CREATE OR REPLACE TRIGGER \(quoteIdentifier("trigger_name")) - BEFORE INSERT ON \(qualified) - FOR EACH ROW - EXECUTE FUNCTION \(fn)(); - """ - } - - func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { - let resolvedSchema = schema ?? core.currentSchema - let query = """ - SELECT pg_get_functiondef(t.tgfoid), pg_get_triggerdef(t.oid) - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE t.tgname = '\(escapeLiteral(name))' - AND c.relname = '\(escapeLiteral(table))' - AND n.nspname = '\(escapeLiteral(resolvedSchema))' - AND NOT t.tgisinternal - LIMIT 1 - """ - let result = try await execute(query: query) - guard let row = result.rows.first, row.count >= 2, - let functionDef = row[0].asText, - let triggerDef = row[1].asText else { return nil } - let editableTrigger: String - if triggerDef.range(of: "CREATE CONSTRAINT TRIGGER", options: .caseInsensitive) != nil { - let drop = generateDropTriggerSQL(name: name, table: table, schema: schema) ?? "" - editableTrigger = "\(drop);\n\(triggerDef)" - } else { - editableTrigger = triggerDef.replacingOccurrences( - of: "CREATE TRIGGER ", - with: "CREATE OR REPLACE TRIGGER " - ) - } - return "\(functionDef);\n\n\(editableTrigger);" - } - - func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { - "DROP TRIGGER IF EXISTS \(quoteIdentifier(name)) ON \(qualifiedTable(table, schema: schema))" - } - /// PostgreSQL allows `f(integer)` and `f(text)` in one schema, so a drop that names only `f` /// is ambiguous and the server refuses it. func generateDropRoutineSQL( @@ -409,64 +277,14 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { var providesBulkForeignKeyFetch: Bool { true } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let query = """ - SELECT - src_cl.relname AS table_name, - con.conname, - src_col.attname, - ref_cl.relname AS referenced_table, - ref_col.attname AS referenced_column, - ref_ns.nspname AS referenced_schema, - CASE con.confdeltype - WHEN 'c' THEN 'CASCADE' - WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' - WHEN 'r' THEN 'RESTRICT' - ELSE 'NO ACTION' - END AS delete_rule, - CASE con.confupdtype - WHEN 'c' THEN 'CASCADE' - WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' - WHEN 'r' THEN 'RESTRICT' - ELSE 'NO ACTION' - END AS update_rule - FROM pg_catalog.pg_constraint con - JOIN pg_catalog.pg_class src_cl ON src_cl.oid = con.conrelid - JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace - JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = con.confrelid - JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace - CROSS JOIN LATERAL unnest(con.conkey, con.confkey) - WITH ORDINALITY AS cols(src_attnum, ref_attnum, ord) - JOIN pg_catalog.pg_attribute src_col - ON src_col.attrelid = con.conrelid AND src_col.attnum = cols.src_attnum - JOIN pg_catalog.pg_attribute ref_col - ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum - WHERE con.contype = 'f' - AND src_ns.nspname = '\(schemaLiteral)' - ORDER BY src_cl.relname, con.conname, cols.ord - """ + let query = PostgreSQLForeignKeyQueries.foreignKeyList( + schema: schema ?? core.currentSchema, table: nil, capabilities: catalogCapabilities + ) let result = try await execute(query: query) var grouped: [String: [PluginForeignKeyInfo]] = [:] for row in result.rows { - guard row.count >= 8, - let tableName = row[0].asText, - let name = row[1].asText, - let column = row[2].asText, - let refTable = row[3].asText, - let refColumn = row[4].asText - else { continue } - let fk = PluginForeignKeyInfo( - name: name, - column: column, - referencedTable: refTable, - referencedColumn: refColumn, - referencedSchema: row[5].asText, - onDelete: row[6].asText ?? "NO ACTION", - onUpdate: row[7].asText ?? "NO ACTION" - ) - grouped[tableName, default: []].append(fk) + guard let decoded = PostgreSQLForeignKeyRow(row) else { continue } + grouped[decoded.table, default: []].append(decoded.foreignKey) } return grouped } @@ -475,7 +293,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let query = """ SELECT reltuples::bigint FROM pg_class - WHERE relname = '\(escapeLiteral(table))' + WHERE relname = \(PostgreSQLObjectQueries.quoteLiteral(table)) AND relnamespace = ( SELECT oid FROM pg_namespace WHERE nspname = current_schema() ) @@ -486,9 +304,9 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchTableDDL(table: String, schema: String?) async throws -> String { - let safeTable = escapeLiteral(table) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) let resolvedSchema = schema ?? core.currentSchema - let schemaLiteral = escapeLiteral(resolvedSchema) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(resolvedSchema) let quotedTable = quoteIdentifier(table) let caps = versionedCapabilities @@ -529,13 +347,14 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { WHEN a.atthasdef \(defaultGuard) THEN ' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid) ELSE '' - END + END, + c.relkind::text FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum - WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(schemaLiteral)' + WHERE c.relname = \(tableLiteral) + AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum @@ -547,8 +366,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(schemaLiteral)' + WHERE c.relname = \(tableLiteral) + AND n.nspname = \(schemaLiteral) AND con.contype IN ('p', 'u', 'c') ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'c' THEN 2 END @@ -559,6 +378,14 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let (cols, cons) = try await (columnsResult, constraintsResult) + /// `pg_attribute` covers views and materialized views as well as tables, so a view reached + /// here as a `CREATE TABLE` of its columns. The Structure tab's DDL and every other caller + /// that asks for a relation's DDL by name get the view's own statement instead. + if let relkind = cols.rows.first?[safe: 1]?.asText, + PostgreSQLViewDefinition.kind(forRelkind: relkind) != nil { + return try await fetchViewDefinition(view: table, schema: resolvedSchema) + } + let columnDefs = cols.rows.compactMap { $0[0].asText } guard !columnDefs.isEmpty else { throw LibPQPluginError(message: "Failed to fetch DDL for table '\(table)'", sqlState: nil, detail: nil) @@ -590,8 +417,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_class c ON c.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(escapeLiteral(table))' - AND n.nspname = '\(escapeLiteral(schema ?? core.currentSchema))' + WHERE c.relname = \(PostgreSQLObjectQueries.quoteLiteral(table)) + AND n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema)) AND NOT EXISTS ( SELECT 1 FROM pg_constraint con WHERE con.conindid = ix.indexrelid ) @@ -601,39 +428,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { return result.rows.compactMap { $0[0].asText } } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let query = """ - SELECT 'CREATE OR REPLACE VIEW ' || quote_ident(schemaname) || '.' || quote_ident(viewname) || ' AS ' || E'\\n' || definition AS ddl - FROM pg_views - WHERE viewname = '\(escapeLiteral(view))' - AND schemaname = '\(schemaLiteral)' - """ - let result = try await execute(query: query) - if let firstRow = result.rows.first, let ddl = firstRow[0].asText { - return ddl - } - - /// `pg_views` excludes materialized views, so a name that is one reaches here with no rows. - /// Falling through to `fetchTableDDL` instead, which succeeds on a matview because - /// `pg_class` and `pg_attribute` both cover one, put a `CREATE TABLE` in the dump under a - /// `DROP MATERIALIZED VIEW` and restored an empty ordinary table. - let matview = """ - SELECT 'CREATE MATERIALIZED VIEW ' || quote_ident(schemaname) || '.' || quote_ident(matviewname) - || ' AS ' || E'\\n' || definition AS ddl - FROM pg_matviews - WHERE matviewname = '\(escapeLiteral(view))' - AND schemaname = '\(schemaLiteral)' - """ - let matviewResult = try await execute(query: matview) - guard let row = matviewResult.rows.first, let ddl = row[0].asText else { - throw LibPQPluginError(message: "Failed to fetch definition for view '\(view)'", sqlState: nil, detail: nil) - } - return ddl - } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema) let query = """ SELECT pg_total_relation_size(c.oid) AS total_size, @@ -643,8 +439,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { obj_description(c.oid, 'pg_class') AS comment FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = '\(escapeLiteral(table))' - AND n.nspname = '\(schemaLiteral)' + WHERE c.relname = \(PostgreSQLObjectQueries.quoteLiteral(table)) + AND n.nspname = \(schemaLiteral) """ let result = try await execute(query: query) guard let row = result.rows.first else { @@ -679,12 +475,12 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - let escapedDbLiteral = escapeLiteral(database) + let databaseLiteral = PostgreSQLObjectQueries.quoteLiteral(database) let query = """ SELECT (SELECT COUNT(*) FROM information_schema.tables t - WHERE t.table_catalog = '\(escapedDbLiteral)' + WHERE t.table_catalog = \(databaseLiteral) AND t.table_schema NOT LIKE 'pg!_%' ESCAPE '!' AND t.table_schema <> 'information_schema' AND NOT EXISTS ( @@ -696,7 +492,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { WHERE cn.nspname = t.table_schema AND child.relname = t.table_name AND parent.relkind IN ('p', 'I'))), - pg_database_size('\(escapedDbLiteral)') + pg_database_size(\(databaseLiteral)) """ let result = try await execute(query: query) let row = result.rows.first @@ -730,19 +526,25 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } } + /// The kinds `fetchTableDDL` writes a `CREATE TABLE` for. A view's DDL is its own statement, so + /// the enum types and sequences its columns happen to use are not a preamble to it: written in + /// front of a `CREATE VIEW` they recreated objects the view only reads. + private static let relkindsCreatedByTableDDL = "('r', 'p', 'f')" + func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] { - let safeTable = escapeLiteral(table) - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema) let query = """ SELECT DISTINCT t.typname, - array_agg(e.enumlabel ORDER BY e.enumsortorder) + array_agg(e.enumlabel ORDER BY e.enumsortorder)::text FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_type t ON t.oid = a.atttypid JOIN pg_enum e ON e.enumtypid = t.oid - WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(schemaLiteral)' + WHERE c.relname = \(tableLiteral) + AND n.nspname = \(schemaLiteral) + AND c.relkind IN \(Self.relkindsCreatedByTableDDL) AND a.attnum > 0 AND NOT a.attisdropped GROUP BY t.typname @@ -751,104 +553,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let result = try await execute(query: query) return result.rows.compactMap { row -> (name: String, labels: [String])? in guard let typeName = row[0].asText, let labelsStr = row[1].asText else { return nil } - let labels = labelsStr - .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) - .components(separatedBy: ",") - return (name: typeName, labels: labels) - } - } - - func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { - guard includesSequencesCatalog() else { return [] } - let safeTable = escapeLiteral(table) - let schemaName = schema ?? core.currentSchema - let schemaLiteral = escapeLiteral(schemaName) - let query = """ - SELECT s.sequencename, - s.start_value, - s.min_value, - s.max_value, - s.increment_by, - s.cycle, - s.last_value - FROM pg_attrdef ad - JOIN pg_class c ON c.oid = ad.adrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_sequences s ON s.schemaname = n.nspname - AND pg_get_expr(ad.adbin, ad.adrelid) LIKE '%' || quote_ident(s.sequencename) || '%' - WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(schemaLiteral)' - AND pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval%' - """ - let result = try await execute(query: query) - return result.rows.compactMap { row -> (name: String, ddl: String)? in - guard let seqName = row[0].asText else { return nil } - let startVal = row[1].asText ?? "1" - let minVal = row[2].asText ?? "1" - let maxVal = row[3].asText ?? "9223372036854775807" - let incrementBy = row[4].asText ?? "1" - let cycle = row[5].asText == "t" ? " CYCLE" : "" - let lastValue = row.count > 6 ? row[6].asText : nil - let quotedSeqName = quoteIdentifier(seqName) - let escapedSeqForLiteral = escapeStringLiteral(seqName) - var ddl = "CREATE SEQUENCE \(quotedSeqName) INCREMENT BY \(incrementBy)" - + " MINVALUE \(minVal) MAXVALUE \(maxVal)" - + " START WITH \(startVal)\(cycle);" - /// Unqualified, so it names the sequence the line above created rather than the one it - /// was read from. `setval` takes a `regclass`, which resolves through `search_path`, and - /// the `CREATE SEQUENCE` beside it is already schema-relative. Spelling the source's own - /// schema here made the pair disagree: run against another schema it repositioned the - /// original sequence, and against another database it named one that was not there. - if let last = lastValue, !last.isEmpty, Int64(last) != nil { - ddl += "\nSELECT pg_catalog.setval('\"\(escapedSeqForLiteral)\"', \(last), true);" - } - return (name: seqName, ddl: ddl) - } - } - - /// Every sequence in the schema, including the ones no column defaults from. - /// - /// `fetchDependentSequences` answers only those a table's `nextval` default reaches, which is - /// what a table dump needs. A sequence a routine or the application advances by hand belongs to - /// nothing, so it is invisible to that query and a dump built only from it restores a database - /// whose sequence is gone. - func fetchSequences(schema: String?) async throws -> [PluginSequenceInfo] { - guard includesSequencesCatalog() else { return [] } - let schemaName = schema ?? core.currentSchema - let schemaLiteral = escapeLiteral(schemaName) - let query = """ - SELECT s.sequencename, - s.start_value, - s.min_value, - s.max_value, - s.increment_by, - s.cycle, - s.last_value, - d.refobjid IS NOT NULL AS owned - FROM pg_sequences s - JOIN pg_class c ON c.relname = s.sequencename - JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = s.schemaname - LEFT JOIN pg_depend d ON d.objid = c.oid AND d.deptype = 'a' - WHERE s.schemaname = '\(schemaLiteral)' - ORDER BY s.sequencename - """ - let result = try await execute(query: query) - return result.rows.compactMap { row -> PluginSequenceInfo? in - guard let name = row[0].asText else { return nil } - let start = row[1].asText ?? "1" - let minValue = row[2].asText ?? "1" - let maxValue = row[3].asText ?? "9223372036854775807" - let increment = row[4].asText ?? "1" - let cycle = row[5].asText == "t" ? " CYCLE" : "" - let lastValue = row.count > 6 ? row[6].asText : nil - let quoted = quoteIdentifier(name) - var ddl = "CREATE SEQUENCE \(quoted) INCREMENT BY \(increment)" - + " MINVALUE \(minValue) MAXVALUE \(maxValue)" - + " START WITH \(start)\(cycle);" - if let last = lastValue, !last.isEmpty, Int64(last) != nil { - ddl += "\nSELECT pg_catalog.setval('\"\(escapeStringLiteral(name))\"', \(last), true);" - } - return PluginSequenceInfo(name: name, ddl: ddl, schema: schemaName) + return (name: typeName, labels: PostgreSQLTextArray.values(labelsStr)) } } @@ -949,7 +654,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { ) } - var sql = "CREATE DATABASE \(quotedName) ENCODING '\(encoding)'" + var sql = "CREATE DATABASE \(quotedName) ENCODING \(PostgreSQLObjectQueries.quoteLiteral(encoding))" let supportsProvider = versionedCapabilities.hasDatabaseICULocale let provider = supportsProvider ? (request.values["provider"] ?? "libc") : "libc" @@ -973,8 +678,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { detail: nil ) } - let escapedCollation = escapeLiteral(collation) - sql += " LC_COLLATE '\(escapedCollation)' LC_CTYPE '\(escapedCollation)'" + let collationLiteral = PostgreSQLObjectQueries.quoteLiteral(collation) + sql += " LC_COLLATE \(collationLiteral) LC_CTYPE \(collationLiteral)" guard let templateDefaults = await templateDefaultsTask else { throw LibPQPluginError( @@ -990,7 +695,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { case "icu": guard supportsProvider else { throw LibPQPluginError( - message: String(localized: "ICU provider requires PostgreSQL 15 or newer"), + message: String(localized: "ICU provider requires PostgreSQL 15 or later"), sqlState: nil, detail: nil ) @@ -1010,11 +715,11 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { detail: nil ) } - let escapedIcu = escapeLiteral(icuLocale) + let icuLiteral = PostgreSQLObjectQueries.quoteLiteral(icuLocale) if versionedCapabilities.hasModernICUSyntax { - sql += " LOCALE_PROVIDER 'icu' LOCALE '\(escapedIcu)' TEMPLATE template0" + sql += " LOCALE_PROVIDER 'icu' LOCALE \(icuLiteral) TEMPLATE template0" } else { - sql += " LOCALE_PROVIDER 'icu' ICU_LOCALE '\(escapedIcu)' LC_COLLATE 'C' LC_CTYPE 'C' TEMPLATE template0" + sql += " LOCALE_PROVIDER 'icu' ICU_LOCALE \(icuLiteral) LC_COLLATE 'C' LC_CTYPE 'C' TEMPLATE template0" } default: @@ -1079,9 +784,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { private func fetchCollations() async -> (libc: [String], icu: [String]) { do { - let result = try await execute( - query: "SELECT collname, collprovider FROM pg_collation WHERE collprovider IN ('b', 'c', 'i') ORDER BY collname" - ) + let result = try await execute(query: PostgreSQLSchemaQueries.collationList(capabilities: catalogCapabilities)) var libc: [String] = [] var icu: [String] = [] for row in result.rows { @@ -1107,27 +810,15 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { - let s = schema ?? currentSchema ?? "public" - return """ - SELECT - schemaname as schema, - relname as name, - 'TABLE' as kind, - n_live_tup as estimated_rows, - pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) as total_size, - pg_size_pretty(pg_relation_size(schemaname||'.'||relname)) as data_size, - pg_size_pretty(pg_indexes_size(schemaname||'.'||relname)) as index_size, - obj_description((schemaname||'.'||relname)::regclass) as comment - FROM pg_stat_user_tables - WHERE schemaname = '\(s)' - ORDER BY relname - """ + PostgreSQLSchemaQueries.allTablesMetadata(schema: schema ?? currentSchema ?? "public") } // MARK: - Create Table DDL func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { - guard !definition.columns.isEmpty else { return nil } + guard !definition.columns.isEmpty, + PostgreSQLVersionedStatements.refusal(for: definition, capabilities: versionedCapabilities) == nil + else { return nil } let schema = core.currentSchema let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" @@ -1225,7 +916,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let unique = index.isUnique ? "UNIQUE " : "" var def = "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) ON \(qualifiedTable)" if let type = index.indexType?.uppercased(), - ["BTREE", "HASH", "GIN", "GIST", "BRIN"].contains(type) { + PostgreSQLVersionedStatements.postgreSQLIndexMethods.contains(type) { def += " USING \(type.lowercased())" } def += " (\(cols))" @@ -1261,10 +952,12 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - Definition SQL (clipboard copy) func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { - pgColumnDefinition(column, inlinePK: false) + guard schemaOperationRefusal(.addColumn(column)) == nil else { return nil } + return pgColumnDefinition(column, inlinePK: false) } func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { + guard schemaOperationRefusal(.addIndex(index)) == nil else { return nil } let qualifiedTable = tableName.map { quoteIdentifier($0) } ?? "\"table\"" return pgIndexDefinition(index, qualifiedTable: qualifiedTable) } @@ -1280,6 +973,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + guard schemaOperationRefusal(.addColumn(column)) == nil else { return nil } let qt = qualifiedTableName(table) let colDef = pgColumnDefinition(column, inlinePK: false) return "ALTER TABLE \(qt) ADD COLUMN \(colDef)" @@ -1317,7 +1011,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } if let newComment = newColumn.comment, !newComment.isEmpty, newColumn.comment != oldColumn.comment { - stmts.append("COMMENT ON COLUMN \(qt).\(colName) IS '\(escapeLiteral(newComment))'") + stmts.append("COMMENT ON COLUMN \(qt).\(colName) IS \(PostgreSQLRelationSQL.commentValue(newComment))") } else if oldColumn.comment != nil && (newColumn.comment == nil || newColumn.comment?.isEmpty == true) { stmts.append("COMMENT ON COLUMN \(qt).\(colName) IS NULL") } @@ -1330,7 +1024,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { - pgIndexDefinition(index, qualifiedTable: qualifiedTableName(table)) + guard schemaOperationRefusal(.addIndex(index)) == nil else { return nil } + return pgIndexDefinition(index, qualifiedTable: qualifiedTableName(table)) } func generateDropIndexSQL(table: String, indexName: String) -> String? { @@ -1358,9 +1053,12 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func generateRenameCheckConstraintSQL(table: String, from oldName: String, to newName: String) -> String? { - guard !oldName.isEmpty, !newName.isEmpty else { return nil } - return "ALTER TABLE \(qualifiedTableName(table)) RENAME CONSTRAINT " - + "\(quoteIdentifier(oldName)) TO \(quoteIdentifier(newName))" + PostgreSQLVersionedStatements.renameConstraint( + qualifiedTable: qualifiedTableName(table), + from: oldName, + to: newName, + capabilities: versionedCapabilities + ) } func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift index 14633fbdf3..cd4023fe2d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift @@ -76,14 +76,21 @@ enum PostgreSQLPrincipalQueries { PluginPrivilegeDescriptor(name: "REFERENCES", label: "References", category: structure) ] - static func searchObjects(patternLiteral: String, limit: Int) -> String { + /// The wildcards are wrapped around the pattern before it is quoted, because an `E` prefix + /// cannot be spliced into the middle of a literal: `ILIKE '%' || E'…' || '%'` would be the only + /// alternative, and one literal is simpler to read. + /// + /// LIKE metacharacters in the typed pattern are deliberately left alone and remain a separate + /// defect: a typed `%` still matches anything, and a typed backslash still acts as LIKE's own + /// escape. Quoting fixes which statement runs, not what the pattern means. + static func searchObjects(pattern: String, limit: Int) -> String { """ SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'v', 'm', 'p', 'f') AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND c.relname ILIKE '%\(patternLiteral)%' + AND c.relname ILIKE \(PostgreSQLObjectQueries.quoteLiteral("%\(pattern)%")) ORDER BY n.nspname, c.relname LIMIT \(max(1, limit)) """ @@ -93,48 +100,51 @@ enum PostgreSQLPrincipalQueries { SELECT n.nspname FROM pg_namespace n WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - AND n.nspname NOT LIKE 'pg\\_%' + AND n.nspname NOT LIKE 'pg!_%' ESCAPE '!' ORDER BY n.nspname """ - static func tables(schemaLiteral: String) -> String { + static func tables(schema: String) -> String { """ SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '\(schemaLiteral)' + WHERE n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) AND c.relkind IN ('r', 'v', 'm', 'p', 'f') ORDER BY c.relname """ } - static func columns(schemaLiteral: String, tableLiteral: String) -> String { + static func columns(schema: String, table: String) -> String { """ SELECT a.attname FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '\(schemaLiteral)' - AND c.relname = '\(tableLiteral)' + WHERE n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND c.relname = \(PostgreSQLObjectQueries.quoteLiteral(table)) AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum """ } - static func columnGrants(roleLiteral: String) -> String { - """ - SELECT n.nspname, c.relname, a.attname, acl.privilege_type, acl.is_grantable - FROM pg_attribute a - JOIN pg_class c ON c.oid = a.attrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - CROSS JOIN LATERAL aclexplode(a.attacl) AS acl - JOIN pg_roles r ON r.oid = acl.grantee - WHERE r.rolname = '\(roleLiteral)' - AND a.attnum > 0 - AND NOT a.attisdropped - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ORDER BY n.nspname, c.relname, a.attname, acl.privilege_type + static func columnGrants(role: String) -> String { + """ + SELECT s.nspname, s.relname, s.attname, (s.acl).privilege_type, (s.acl).is_grantable + FROM ( + SELECT n.nspname, c.relname, a.attname, pg_catalog.aclexplode(a.attacl) AS acl + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE a.attacl IS NOT NULL + AND a.attnum > 0 + AND NOT a.attisdropped + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ) s + JOIN pg_roles r ON r.oid = (s.acl).grantee + WHERE r.rolname = \(PostgreSQLObjectQueries.quoteLiteral(role)) + ORDER BY s.nspname, s.relname, s.attname, (s.acl).privilege_type """ } @@ -152,7 +162,7 @@ enum PostgreSQLPrincipalQueries { r.rolconnlimit, pg_catalog.shobj_description(r.oid, 'pg_authid') FROM pg_roles r - WHERE r.rolname NOT LIKE 'pg\\_%' + WHERE r.rolname NOT LIKE 'pg!_%' ESCAPE '!' ORDER BY r.rolname """ } @@ -165,51 +175,60 @@ enum PostgreSQLPrincipalQueries { ORDER BY member.rolname, grantedRole.rolname """ - static func databaseGrants(roleLiteral: String) -> String { + static func databaseGrants(role: String) -> String { """ - SELECT d.datname, a.privilege_type, a.is_grantable - FROM pg_database d - CROSS JOIN LATERAL aclexplode(d.datacl) AS a - JOIN pg_roles r ON r.oid = a.grantee - WHERE r.rolname = '\(roleLiteral)' - AND NOT d.datistemplate - ORDER BY d.datname, a.privilege_type + SELECT s.datname, (s.acl).privilege_type, (s.acl).is_grantable + FROM ( + SELECT d.datname, pg_catalog.aclexplode(d.datacl) AS acl + FROM pg_database d + WHERE d.datacl IS NOT NULL + AND NOT d.datistemplate + ) s + JOIN pg_roles r ON r.oid = (s.acl).grantee + WHERE r.rolname = \(PostgreSQLObjectQueries.quoteLiteral(role)) + ORDER BY s.datname, (s.acl).privilege_type """ } - static func schemaGrants(roleLiteral: String) -> String { + static func schemaGrants(role: String) -> String { """ - SELECT n.nspname, a.privilege_type, a.is_grantable - FROM pg_namespace n - CROSS JOIN LATERAL aclexplode(n.nspacl) AS a - JOIN pg_roles r ON r.oid = a.grantee - WHERE r.rolname = '\(roleLiteral)' - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ORDER BY n.nspname, a.privilege_type + SELECT s.nspname, (s.acl).privilege_type, (s.acl).is_grantable + FROM ( + SELECT n.nspname, pg_catalog.aclexplode(n.nspacl) AS acl + FROM pg_namespace n + WHERE n.nspacl IS NOT NULL + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ) s + JOIN pg_roles r ON r.oid = (s.acl).grantee + WHERE r.rolname = \(PostgreSQLObjectQueries.quoteLiteral(role)) + ORDER BY s.nspname, (s.acl).privilege_type """ } - static func tableGrants(roleLiteral: String) -> String { - """ - SELECT n.nspname, c.relname, a.privilege_type, a.is_grantable - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - CROSS JOIN LATERAL aclexplode(c.relacl) AS a - JOIN pg_roles r ON r.oid = a.grantee - WHERE r.rolname = '\(roleLiteral)' - AND c.relkind IN ('r', 'v', 'm', 'p', 'f') - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ORDER BY n.nspname, c.relname, a.privilege_type + static func tableGrants(role: String) -> String { + """ + SELECT s.nspname, s.relname, (s.acl).privilege_type, (s.acl).is_grantable + FROM ( + SELECT n.nspname, c.relname, pg_catalog.aclexplode(c.relacl) AS acl + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relacl IS NOT NULL + AND c.relkind IN ('r', 'v', 'm', 'p', 'f') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ) s + JOIN pg_roles r ON r.oid = (s.acl).grantee + WHERE r.rolname = \(PostgreSQLObjectQueries.quoteLiteral(role)) + ORDER BY s.nspname, s.relname, (s.acl).privilege_type """ } - static func ownsObjects(roleLiteral: String) -> String { + static func ownsObjects(role: String) -> String { """ SELECT EXISTS ( SELECT 1 FROM pg_shdepend s JOIN pg_roles r ON r.oid = s.refobjid - WHERE r.rolname = '\(roleLiteral)' + WHERE r.rolname = \(PostgreSQLObjectQueries.quoteLiteral(role)) AND s.deptype IN ('o', 'a') ) """ diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLRelationSQL.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLRelationSQL.swift new file mode 100644 index 0000000000..1e220eadcd --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLRelationSQL.swift @@ -0,0 +1,112 @@ +// +// PostgreSQLRelationSQL.swift +// PostgreSQLDriverPlugin +// +// Statements that act on one table-like relation by name: comments and materialized view +// refreshes. Pure, so it is testable without a server. +// + +import Foundation +import TableProPluginKit + +public enum PostgreSQLRelationSQL { + /// The keyword `COMMENT ON` needs for each relation kind. PostgreSQL checks it against the + /// relation's `relkind` and refuses a mismatch: `COMMENT ON TABLE` on a view fails with + /// "is not a table", and `COMMENT ON VIEW` on a materialized view with "is not a view". + public static func commentKeyword(forObjectType objectType: String) -> String? { + switch objectType.uppercased() { + case "TABLE", "PARTITIONED TABLE": + return "TABLE" + case "VIEW": + return "VIEW" + case "MATERIALIZED VIEW": + return "MATERIALIZED VIEW" + case "FOREIGN TABLE": + return "FOREIGN TABLE" + default: + return nil + } + } + + /// The same keyword read from `pg_class.relkind`, which is what a catalog read has to hand. A + /// partitioned table takes `TABLE`, the keyword `COMMENT ON PARTITIONED TABLE` does not exist. + public static func commentKeyword(forRelkind relkind: String) -> String? { + switch relkind { + case "r", "p": + return "TABLE" + case "f": + return "FOREIGN TABLE" + case "v": + return "VIEW" + case "m": + return "MATERIALIZED VIEW" + default: + return nil + } + } + + /// `COMMENT` takes a literal and nothing else, so the value is quoted here rather than bound. The + /// quoting reads the same whatever `standard_conforming_strings` is set to. + public static func commentStatement( + name: String, + schema: String, + objectType: String, + comment: String? + ) -> String? { + guard let keyword = commentKeyword(forObjectType: objectType) else { return nil } + let target = PostgreSQLObjectQueries.qualifiedName(schema: schema, name: name) + return "COMMENT ON \(keyword) \(target) IS \(commentValue(comment))" + } + + public static func commentValue(_ comment: String?) -> String { + guard let comment, !comment.isEmpty else { return "NULL" } + return PostgreSQLObjectQueries.quoteLiteral(comment) + } + + public static func refreshStatement(name: String, schema: String, concurrently: Bool) -> String { + let target = PostgreSQLObjectQueries.qualifiedName(schema: schema, name: name) + return concurrently + ? "REFRESH MATERIALIZED VIEW CONCURRENTLY \(target)" + : "REFRESH MATERIALIZED VIEW \(target)" + } + + /// A concurrent refresh diffs the new result against the stored rows through a unique index, + /// so it needs one the server can use for every row: valid, immediate, on plain columns and + /// without a predicate. It also needs rows to diff against, which an unpopulated view lacks. + /// Measured on PostgreSQL 17 against every index shape `scripts/check-postgres-matview-refresh.sh` + /// builds; that script re-checks the predicate against a live server. + public static func concurrentRefreshQuery(name: String, schema: String) -> String { + """ + SELECT + CASE WHEN c.relispopulated THEN 1 ELSE 0 END, + CASE WHEN EXISTS ( + SELECT 1 + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class ic ON ic.oid = i.indexrelid + JOIN pg_catalog.pg_am am ON am.oid = ic.relam + WHERE i.indrelid = c.oid + AND i.indisunique + AND i.indimmediate + AND i.indisvalid + AND i.indpred IS NULL + AND i.indexprs IS NULL + AND am.amname = 'btree' + ) THEN 1 ELSE 0 END + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'm' + AND n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND c.relname = \(PostgreSQLObjectQueries.quoteLiteral(name)) + """ + } + + /// Population is checked first: an unpopulated view is refused even when its index is fine, and + /// the fix the user needs is a plain refresh, not a new index. + public static func concurrentRefreshAvailability( + isPopulated: Bool, + hasUsableUniqueIndex: Bool + ) -> PluginConcurrentRefreshAvailability { + guard isPopulated else { return .requiresPopulatedView } + return hasUsableUniqueIndex ? .available : .requiresUniqueIndex + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index c82fc65d5e..7e41070945 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -23,7 +23,7 @@ enum PostgreSQLSchemaQueries { /// Like `current_schema()`, but resolves via `current_schemas(false)`, /// which omits search path entries that do not correspond to existing, /// searchable schemas. - static let firstSearchPathSchema = "SELECT current_schemas(false)[1]" + static let firstSearchPathSchema = "SELECT (current_schemas(false))[1]" /// Queries tried in order when `current_schema()` resolves to NULL, so a /// database without a `public` schema still gets a usable default schema @@ -73,9 +73,10 @@ enum PostgreSQLSchemaQueries { /// implement; the caller passes `false` when those catalogs are absent so /// the whole query does not fail with `relation does not exist`. /// - /// `includeComments` projects each table's comment via `obj_description` / - /// `to_regclass`. Engines that lack those functions fail the whole listing, - /// so the caller passes `false` to fall back to a comment-free listing. + /// `includeComments` projects each table's comment via `obj_description` + /// over the relation's oid. Engines that lack that function fail the whole + /// listing, so the caller passes `false` to fall back to a comment-free + /// listing. /// /// `includePartitionAwareness` labels a declarative partition parent as /// `PARTITIONED TABLE` and drops its partition children, which @@ -95,17 +96,18 @@ enum PostgreSQLSchemaQueries { /// ordinary table (`relkind = 'r'`), and they are independently useful /// tables rather than an implementation detail of one parent. static func fetchTables( - schemaLiteral: String, + schema: String, includeMaterializedViews: Bool, includeForeignTables: Bool, includeComments: Bool = true, includePartitionAwareness: Bool = true ) -> String { - func commentColumn(_ expression: String) -> String { - includeComments ? expression : "NULL::text" + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + func commentColumn(_ oidExpression: String) -> String { + includeComments ? "obj_description(\(oidExpression), 'pg_class')" : "NULL::text" } - let partitionJoin = includePartitionAwareness ? """ + let classJoin = (includeComments || includePartitionAwareness) ? """ LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = t.table_schema LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = t.table_name @@ -128,20 +130,25 @@ enum PostgreSQLSchemaQueries { var unions: [String] = [ """ SELECT t.table_name, \(tableTypeColumn) AS table_type, - \(commentColumn("obj_description(to_regclass(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name)), 'pg_class')")) AS table_comment - FROM information_schema.tables t\(partitionJoin) - WHERE t.table_schema = '\(schemaLiteral)' + \(commentColumn("pc.oid")) AS table_comment + FROM information_schema.tables t\(classJoin) + WHERE t.table_schema = \(schemaLiteral) AND t.table_type IN ('BASE TABLE', 'VIEW')\(partitionFilter) """ ] if includeMaterializedViews { + let matviewJoin = includeComments ? """ + + LEFT JOIN pg_catalog.pg_namespace mn ON mn.nspname = m.schemaname + LEFT JOIN pg_catalog.pg_class mc ON mc.relnamespace = mn.oid AND mc.relname = m.matviewname + """ : "" unions.append( """ SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, - \(commentColumn("obj_description(to_regclass(quote_ident(m.schemaname) || '.' || quote_ident(m.matviewname)), 'pg_class')")) AS table_comment - FROM pg_matviews m - WHERE m.schemaname = '\(schemaLiteral)' + \(commentColumn("mc.oid")) AS table_comment + FROM pg_matviews m\(matviewJoin) + WHERE m.schemaname = \(schemaLiteral) """ ) } @@ -150,11 +157,11 @@ enum PostgreSQLSchemaQueries { unions.append( """ SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type, - \(commentColumn("obj_description(c.oid, 'pg_class')")) AS table_comment + \(commentColumn("c.oid")) AS table_comment FROM pg_foreign_table ft JOIN pg_class c ON c.oid = ft.ftrelid JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '\(schemaLiteral)' + WHERE n.nspname = \(schemaLiteral) """ ) } @@ -169,15 +176,15 @@ enum PostgreSQLSchemaQueries { /// `relpartbound` exists only from PostgreSQL 10, so unlike `fetchTables` /// this query cannot be issued against an older server. The caller gates it /// on `PostgreSQLCapabilities.hasDeclarativePartitioning`. - static func fetchPartitions(schemaLiteral: String, tableLiteral: String) -> String { + static func fetchPartitions(schema: String, table: String) -> String { """ SELECT cc.relname, cc.relkind FROM pg_catalog.pg_inherits i JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent JOIN pg_catalog.pg_namespace pn ON pn.oid = parent.relnamespace JOIN pg_catalog.pg_class cc ON cc.oid = i.inhrelid - WHERE pn.nspname = '\(schemaLiteral)' - AND parent.relname = '\(tableLiteral)' + WHERE pn.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND parent.relname = \(PostgreSQLObjectQueries.quoteLiteral(table)) AND parent.relkind = 'p' ORDER BY pg_catalog.pg_get_expr(cc.relpartbound, cc.oid) = 'DEFAULT', cc.relname """ @@ -188,13 +195,30 @@ enum PostgreSQLSchemaQueries { return "SET search_path TO \(quotedIdentifier)" } - /// Column introspection for one schema. Passing `tableLiteral` restricts the - /// result to a single table; passing `nil` returns every table's columns and - /// prefixes each row with `table_name`. `schemaLiteral` is the only schema - /// source, so the caller resolves the target schema (qualified reference, - /// then current schema) before escaping and passing it here. The identity, - /// generated, and attribute-join fragments come from the connected server's - /// versioned capabilities. + static func collationList(capabilities: PostgreSQLCapabilities) -> String { + guard capabilities.hasCollationProvider else { + return "SELECT collname, 'c' FROM pg_catalog.pg_collation WHERE oid <> 100 ORDER BY collname" + } + return "SELECT collname, collprovider FROM pg_catalog.pg_collation WHERE collprovider IN ('b', 'c', 'i') ORDER BY collname" + } + + static func allTablesMetadata(schema: String) -> String { + """ + SELECT + schemaname as schema, + relname as name, + 'TABLE' as kind, + n_live_tup as estimated_rows, + pg_size_pretty(pg_total_relation_size(relid)) as total_size, + pg_size_pretty(pg_relation_size(relid)) as data_size, + pg_size_pretty(pg_indexes_size(relid)) as index_size, + obj_description(relid, 'pg_class') as comment + FROM pg_stat_user_tables + WHERE schemaname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + ORDER BY relname + """ + } + static let enumTypeOidQuery = """ SELECT t.oid::text, t.typarray::text, t.typname FROM pg_catalog.pg_type t @@ -224,70 +248,190 @@ enum PostgreSQLSchemaQueries { /// `conkey` carries the attribute numbers the constraint touches, so the columns involved come /// from the catalog rather than from parsing the expression. `pg_get_constraintdef` is the only /// supported way to read the text: `consrc` was removed in PostgreSQL 12. - static func checkConstraintsQuery(schemaLiteral: String, tableLiteral: String) -> String { - """ + static func checkConstraintsQuery(schema: String, table: String) -> String { + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + return """ SELECT con.conname, pg_get_constraintdef(con.oid), con.convalidated, COALESCE(( - SELECT to_json(array_agg(att.attname ORDER BY att.attnum))::text - FROM unnest(con.conkey) AS k(attnum) - JOIN pg_catalog.pg_attribute att - ON att.attrelid = con.conrelid AND att.attnum = k.attnum - ), \'[]\') + SELECT array_agg(att.attname ORDER BY att.attnum)::text + FROM pg_catalog.pg_attribute att + WHERE att.attrelid = con.conrelid AND att.attnum = ANY (con.conkey) + ), \'{}\') FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class cls ON cls.oid = con.conrelid JOIN pg_catalog.pg_namespace ns ON ns.oid = cls.relnamespace WHERE con.contype = \'c\' - AND ns.nspname = \'\(schemaLiteral)\' - AND cls.relname = \'\(tableLiteral)\' + AND ns.nspname = \(schemaLiteral) + AND cls.relname = \(tableLiteral) ORDER BY con.conname """ } + /// Column introspection for one schema. Passing `table` restricts the result to a single table; + /// passing `nil` returns every table's columns and prefixes each row with `table_name`. + /// `schema` is the only schema source, so the caller resolves the target schema (qualified + /// reference, then current schema) and passes it raw; quoting happens here. The identity, + /// generated, and attribute-join fragments come from the connected server's versioned + /// capabilities. + /// + /// `includeMaterializedViews` appends a second arm for `relkind = 'm'`. + /// `information_schema.columns` is defined with `relkind = ANY (ARRAY['r','v','f','p'])`, so a + /// materialized view has no rows there at all and both its Structure tab and its autocomplete + /// came back empty. The arm reproduces `information_schema.columns`' own type, collation, + /// nullability, comment and privilege expressions rather than replacing the base, so the + /// relation kinds that already worked keep byte-identical rows. The caller gates it on probed + /// catalog presence rather than on the server version, because a PostgreSQL-compatible engine + /// can report a recent version and still have no materialized views (#1383). static func columnsQuery( - schemaLiteral: String, - tableLiteral: String?, - identityProjection: String, - generatedProjection: String, - generationExpressionProjection: String, - attributeJoin: String + schema: String, + table: String?, + capabilities: PostgreSQLCapabilities, + includeMaterializedViews: Bool ) -> String { - let shape = ColumnQueryShape.fragments(tableLiteral: tableLiteral) - return """ + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + let shape = ColumnQueryShape.fragments(table: table) + let includesTableName = table == nil + let identityProjection = capabilities.hasIdentityColumns ? "a.attidentity" : "NULL::text" + let generatedProjection = capabilities.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" + let generationExpressionProjection = capabilities.hasGeneratedColumns + ? "c.generation_expression" + : "NULL::text" + let attributeJoin = (capabilities.hasIdentityColumns || capabilities.hasGeneratedColumns) ? """ + + LEFT JOIN pg_catalog.pg_attribute a + ON a.attrelid = rel.oid + AND a.attnum = c.ordinal_position + """ : "" + let informationSchemaArm = """ SELECT - \(shape.selectPrefix)c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.collation_name, - pgd.description, - c.udt_name, + \(includesTableName ? "c.table_name AS table_name,\n " : "")c.column_name AS column_name, + c.data_type AS data_type, + c.is_nullable AS is_nullable, + c.column_default AS column_default, + c.collation_name AS collation_name, + pg_catalog.col_description(rel.oid, c.ordinal_position) AS column_comment, + c.udt_name AS udt_name, CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk, - \(identityProjection), - \(generatedProjection), - c.udt_schema, - \(generationExpressionProjection) + \(identityProjection) AS identity_kind, + \(generatedProjection) AS generated_kind, + c.udt_schema AS udt_schema, + \(generationExpressionProjection) AS generation_expression, + c.ordinal_position AS ordinal_position FROM information_schema.columns c - LEFT JOIN pg_catalog.pg_statio_all_tables st - ON st.schemaname = c.table_schema - AND st.relname = c.table_name - LEFT JOIN pg_catalog.pg_description pgd - ON pgd.objoid = st.relid - AND pgd.objsubid = c.ordinal_position - \(attributeJoin) - LEFT JOIN ( - SELECT DISTINCT \(shape.pkSelect) - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(schemaLiteral)'\(shape.pkTableFilter) - ) pk ON \(shape.pkJoin) - WHERE c.table_schema = '\(schemaLiteral)'\(shape.mainTableFilter) - ORDER BY \(shape.orderBy) + LEFT JOIN pg_catalog.pg_namespace relns + ON relns.nspname = c.table_schema + LEFT JOIN pg_catalog.pg_class rel + ON rel.relnamespace = relns.oid + AND rel.relname = c.table_name\(attributeJoin) + \(ColumnQueryShape.primaryKeyJoin(schema: schema, fragments: shape)) + WHERE c.table_schema = \(schemaLiteral)\(shape.mainTableFilter) """ + var arms = [informationSchemaArm] + if includeMaterializedViews { + arms.append( + materializedViewColumnsArm( + schemaLiteral: schemaLiteral, + table: table, + capabilities: capabilities, + includesTableName: includesTableName + ) + ) + } + let orderBy = includesTableName ? "cols.table_name, cols.ordinal_position" : "cols.ordinal_position" + return """ + SELECT + \(columnsOuterProjection(includesTableName: includesTableName)) + FROM ( + \(arms.joined(separator: "\nUNION ALL\n")) + ) cols + ORDER BY \(orderBy) + """ + } + + /// The order `PostgreSQLPluginDriver.mapPgColumnRow` reads the row in. It maps by position, so + /// this list is the contract between the two arms of `columnsQuery` and the mapper. + /// `ordinal_position` stays inside the derived table, named only by the outer `ORDER BY`. + private static func columnsOuterProjection(includesTableName: Bool) -> String { + let columns = (includesTableName ? ["cols.table_name"] : []) + [ + "cols.column_name", + "cols.data_type", + "cols.is_nullable", + "cols.column_default", + "cols.collation_name", + "cols.column_comment", + "cols.udt_name", + "cols.is_pk", + "cols.identity_kind", + "cols.generated_kind", + "cols.udt_schema", + "cols.generation_expression" + ] + return columns.joined(separator: ",\n ") + } + + /// A materialized view's columns, built from `information_schema.columns`' own expressions so a + /// matview column reaches `PostgresColumnTypeResolver` with the same `data_type`, `udt_name` and + /// `udt_schema` a table column does. The `NULL` typmod in `format_type` is deliberate: + /// `information_schema` also spells `numeric(10,2)` as `numeric`, and diverging here would + /// classify one column two different ways depending on which relation it sits in. + /// + /// There is no `pg_attrdef` join and no primary key lookup because PostgreSQL gives a + /// materialized view column neither a default nor a constraint. + private static func materializedViewColumnsArm( + schemaLiteral: String, + table: String?, + capabilities: PostgreSQLCapabilities, + includesTableName: Bool + ) -> String { + let tableNameProjection = includesTableName ? "mvc.relname AS table_name,\n " : "" + let tableFilter = table.map { "\n AND mvc.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" + let identityProjection = capabilities.hasIdentityColumns ? "mva.attidentity" : "NULL::text" + let generatedProjection = capabilities.hasGeneratedColumns ? "mva.attgenerated" : "NULL::text" + return """ + SELECT + \(tableNameProjection)mva.attname AS column_name, + CASE WHEN mvt.typtype = 'd' + THEN CASE WHEN mvbt.typelem <> 0 AND mvbt.typlen = -1 THEN 'ARRAY' + WHEN mvbtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mvt.typbasetype, NULL) + ELSE 'USER-DEFINED' END + ELSE CASE WHEN mvt.typelem <> 0 AND mvt.typlen = -1 THEN 'ARRAY' + WHEN mvtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mva.atttypid, NULL) + ELSE 'USER-DEFINED' END + END AS data_type, + CASE WHEN mva.attnotnull OR (mvt.typtype = 'd' AND mvt.typnotnull) THEN 'NO' ELSE 'YES' END AS is_nullable, + NULL::text AS column_default, + CASE WHEN mvcon.nspname <> 'pg_catalog' OR mvco.collname <> 'default' THEN mvco.collname END AS collation_name, + pg_catalog.col_description(mvc.oid, mva.attnum) AS column_comment, + COALESCE(mvbt.typname, mvt.typname) AS udt_name, + 'NO' AS is_pk, + \(identityProjection) AS identity_kind, + \(generatedProjection) AS generated_kind, + COALESCE(mvbtn.nspname, mvtn.nspname) AS udt_schema, + NULL::text AS generation_expression, + mva.attnum AS ordinal_position + FROM pg_catalog.pg_class mvc + JOIN pg_catalog.pg_namespace mvn ON mvn.oid = mvc.relnamespace + JOIN pg_catalog.pg_attribute mva + ON mva.attrelid = mvc.oid + AND mva.attnum > 0 + AND NOT mva.attisdropped + JOIN pg_catalog.pg_type mvt ON mvt.oid = mva.atttypid + JOIN pg_catalog.pg_namespace mvtn ON mvtn.oid = mvt.typnamespace + LEFT JOIN pg_catalog.pg_type mvbt + ON mvt.typtype = 'd' + AND mvbt.oid = mvt.typbasetype + LEFT JOIN pg_catalog.pg_namespace mvbtn ON mvbtn.oid = mvbt.typnamespace + LEFT JOIN pg_catalog.pg_collation mvco ON mvco.oid = mva.attcollation + LEFT JOIN pg_catalog.pg_namespace mvcon ON mvcon.oid = mvco.collnamespace + WHERE mvc.relkind = 'm' + AND mvn.nspname = \(schemaLiteral)\(tableFilter) + AND NOT pg_catalog.pg_is_other_temp_schema(mvn.oid) + AND (pg_catalog.pg_has_role(mvc.relowner, 'USAGE') + OR pg_catalog.has_column_privilege(mvc.oid, mva.attnum, 'SELECT, INSERT, UPDATE, REFERENCES')) + """ } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSequenceQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSequenceQueries.swift new file mode 100644 index 0000000000..35dbb6ef07 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSequenceQueries.swift @@ -0,0 +1,160 @@ +// +// PostgreSQLSequenceQueries.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +struct PostgreSQLSequenceDefinition: Equatable, Sendable { + let name: String + let startValue: String? + let minValue: String? + let maxValue: String? + let increment: String? + let cycles: Bool + let lastValue: String? + let needsLastValueRead: Bool + + func withLastValue(_ value: String?) -> PostgreSQLSequenceDefinition { + PostgreSQLSequenceDefinition( + name: name, + startValue: startValue, + minValue: minValue, + maxValue: maxValue, + increment: increment, + cycles: cycles, + lastValue: value, + needsLastValueRead: false + ) + } + + var ddl: String { + let quotedName = PostgreSQLObjectQueries.quoteIdentifier(name) + let parameters = [startValue, minValue, maxValue, increment] + var statement: String + if parameters.allSatisfy({ $0 == nil }) { + statement = "CREATE SEQUENCE \(quotedName);" + } else { + statement = "CREATE SEQUENCE \(quotedName) INCREMENT BY \(increment ?? "1")" + + " MINVALUE \(minValue ?? "1") MAXVALUE \(maxValue ?? "9223372036854775807")" + + " START WITH \(startValue ?? "1")\(cycles ? " CYCLE" : "");" + } + guard let lastValue, Int64(lastValue) != nil else { return statement } + let target = PostgreSQLObjectQueries.quoteLiteral(quotedName) + statement += "\nSELECT pg_catalog.setval(\(target), \(lastValue), true);" + return statement + } +} + +enum PostgreSQLSequenceQueries { + enum Source: Equatable { + case sequencesView + case sequenceParameters + } + + /// `pg_sequence_parameters` answers on every supported release, so a server whose version + /// promises `pg_sequences` but whose catalog does not carry it still lists its sequences. + static func source(hasSequencesCatalog: Bool) -> Source { + hasSequencesCatalog ? .sequencesView : .sequenceParameters + } + + static func sequenceList(schema: String, dependentOnTable table: String?, source: Source) -> String { + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + let dependency = table.map { dependencyPredicate(schemaLiteral: schemaLiteral, table: $0) } ?? "" + switch source { + case .sequencesView: + return """ + SELECT s.sequencename, + s.start_value, + s.min_value, + s.max_value, + s.increment_by, + s.cycle, + s.last_value, + false AS needs_last_value_read + FROM pg_catalog.pg_sequences s + JOIN pg_catalog.pg_namespace n ON n.nspname = s.schemaname + JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid AND c.relname = s.sequencename + WHERE s.schemaname = \(schemaLiteral)\(dependency) + ORDER BY s.sequencename + """ + case .sequenceParameters: + return """ + SELECT s.relname, + \(parameter("start_value")), + \(parameter("minimum_value")), + \(parameter("maximum_value")), + \(parameter("increment")), + \(parameter("cycle_option")), + NULL::bigint AS last_value, + s.readable + FROM ( + SELECT c.oid, + c.relname, + pg_catalog.has_sequence_privilege(c.oid, 'SELECT,USAGE,UPDATE') AS can_read_parameters, + pg_catalog.has_sequence_privilege(c.oid, 'SELECT') AS readable + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'S' + AND n.nspname = \(schemaLiteral)\(dependency) + OFFSET 0 + ) s + ORDER BY s.relname + """ + } + } + + /// The privilege test runs once per sequence in the fenced derived table (`OFFSET 0` stops it + /// being flattened back into every CASE). The parameters cannot be read once the same way: + /// `pg_sequence_parameters` returns an anonymous record that loses its type in a derived-table + /// column (9.1 and 9.3 answer "record type has not been registered"), and moving the privilege + /// test into WHERE lets the server run it against relations that are not sequences. + private static func parameter(_ field: String) -> String { + """ + CASE WHEN s.can_read_parameters + THEN (pg_catalog.pg_sequence_parameters(s.oid)).\(field) END + """ + } + + /// One statement per sequence. A sequence dropped or revoked between the listing and this read + /// then costs its own `setval` line rather than the whole listing. + static func lastValue(schema: String, sequence: String) -> String { + let relation = PostgreSQLObjectQueries.qualifiedName(schema: schema, name: sequence) + return "SELECT CASE WHEN is_called THEN last_value END FROM \(relation)" + } + + static func definitions(from rows: [[PluginCellValue]]) -> [PostgreSQLSequenceDefinition] { + rows.compactMap { row in + guard let name = row[safe: 0]?.asText else { return nil } + return PostgreSQLSequenceDefinition( + name: name, + startValue: row[safe: 1]?.asText, + minValue: row[safe: 2]?.asText, + maxValue: row[safe: 3]?.asText, + increment: row[safe: 4]?.asText, + cycles: PostgreSQLCatalogBoolean.isTrue(row[safe: 5]?.asText), + lastValue: row[safe: 6]?.asText, + needsLastValueRead: PostgreSQLCatalogBoolean.isTrue(row[safe: 7]?.asText) + ) + } + } + + private static func dependencyPredicate(schemaLiteral: String, table: String) -> String { + """ + + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_attrdef ad + JOIN pg_catalog.pg_depend d + ON d.classid = 'pg_catalog.pg_attrdef'::pg_catalog.regclass + AND d.objid = ad.oid + AND d.refclassid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND d.refobjid = c.oid + JOIN pg_catalog.pg_class t ON t.oid = ad.adrelid + JOIN pg_catalog.pg_namespace tn ON tn.oid = t.relnamespace + WHERE t.relname = \(PostgreSQLObjectQueries.quoteLiteral(table)) + AND tn.nspname = \(schemaLiteral)) + """ + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift new file mode 100644 index 0000000000..90b946e985 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift @@ -0,0 +1,21 @@ +// +// PostgreSQLTextArray.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum PostgreSQLTextArray { + static func elements(_ text: String?) -> [String?] { + guard let text, let parsed = PostgresArrayLiteralCodec.parse(text) else { return [] } + return parsed.map { element in + guard case .value(let value) = element else { return nil } + return value + } + } + + static func values(_ text: String?) -> [String] { + elements(text).compactMap { $0 } + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift index 397b9661a9..e8613a649f 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift @@ -114,12 +114,15 @@ public enum PostgreSQLTypeDefinition { case owner case comment case enumLabels - case fields + case fieldNames + case fieldTypes + case fieldCollations case baseType case collation case isNotNull case defaultValue - case constraints + case constraintNames + case constraintDefinitions case rangeSubtype case rangeCanonical case rangeSubtypeDiff @@ -142,19 +145,13 @@ public enum PostgreSQLTypeDefinition { kind: kind, owner: text(row, .owner), comment: text(row, .comment), - enumLabels: jsonStrings(text(row, .enumLabels)), - fields: jsonObjects(text(row, .fields)).compactMap { object in - guard let name = object["name"], let type = object["type"] else { return nil } - return PluginUserDefinedTypeField(name: name, type: type, collation: object["collation"]) - }, + enumLabels: PostgreSQLTextArray.values(text(row, .enumLabels)), + fields: fields(in: row), baseType: text(row, .baseType), collation: text(row, .collation), - isNotNull: text(row, .isNotNull) == "t" || text(row, .isNotNull) == "true", + isNotNull: PostgreSQLCatalogBoolean.isTrue(text(row, .isNotNull)), defaultValue: text(row, .defaultValue), - constraints: jsonObjects(text(row, .constraints)).compactMap { object in - guard let name = object["name"], let definition = object["definition"] else { return nil } - return PostgreSQLDomainConstraint(name: name, definition: definition) - }, + constraints: constraints(in: row), rangeSubtype: text(row, .rangeSubtype), rangeCanonical: text(row, .rangeCanonical), rangeSubtypeDiff: text(row, .rangeSubtypeDiff), @@ -298,19 +295,23 @@ public enum PostgreSQLTypeDefinition { return value } - private static func jsonStrings(_ json: String?) -> [String] { - guard let data = json?.data(using: .utf8), - let array = try? JSONSerialization.jsonObject(with: data) as? [Any] - else { return [] } - return array.compactMap { $0 as? String } + private static func fields(in row: [PluginCellValue]) -> [PluginUserDefinedTypeField] { + let names = PostgreSQLTextArray.elements(text(row, .fieldNames)) + let types = PostgreSQLTextArray.elements(text(row, .fieldTypes)) + let collations = PostgreSQLTextArray.elements(text(row, .fieldCollations)) + return names.indices.compactMap { index in + guard let name = names[index], let type = types[safe: index] ?? nil else { return nil } + let collation = collations[safe: index] ?? nil + return PluginUserDefinedTypeField(name: name, type: type, collation: collation) + } } - private static func jsonObjects(_ json: String?) -> [[String: String]] { - guard let data = json?.data(using: .utf8), - let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] - else { return [] } - return array.map { object in - object.compactMapValues { $0 as? String } + private static func constraints(in row: [PluginCellValue]) -> [PostgreSQLDomainConstraint] { + let names = PostgreSQLTextArray.elements(text(row, .constraintNames)) + let definitions = PostgreSQLTextArray.elements(text(row, .constraintDefinitions)) + return names.indices.compactMap { index in + guard let name = names[index], let definition = definitions[safe: index] ?? nil else { return nil } + return PostgreSQLDomainConstraint(name: name, definition: definition) } } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLVersionedStatements.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLVersionedStatements.swift new file mode 100644 index 0000000000..24c7fbd9e6 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLVersionedStatements.swift @@ -0,0 +1,181 @@ +// +// PostgreSQLVersionedStatements.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +internal struct PostgreSQLSessionFacts: Sendable, Equatable { + static let probeQuery = "SELECT pg_catalog.current_database(), pg_catalog.current_setting('server_version_num')" + static let unknown = PostgreSQLSessionFacts(database: nil, serverVersion: nil) + + let database: String? + let serverVersion: Int32? + + init(database: String?, serverVersion: Int32?) { + self.database = database + self.serverVersion = serverVersion + } + + init(probeRow row: [String?]) { + let database = row.first.flatMap { $0 }.flatMap { $0.isEmpty ? nil : $0 } + let versionText = row.count > 1 ? row[1]?.trimmingCharacters(in: .whitespaces) : nil + let version = versionText.flatMap { Int32($0) }.flatMap { $0 > 0 ? $0 : nil } + self.init(database: database, serverVersion: version) + } + + func resolvedServerVersion(reported: Int32) -> Int32 { + guard reported <= 0 else { return reported } + return serverVersion ?? 0 + } +} + +internal enum PostgreSQLVersionedStatements { + static let postgreSQLIndexMethods: Set = ["BTREE", "HASH", "GIN", "GIST", "BRIN"] + static let mySQLOnlyIndexTypes: Set = ["FULLTEXT", "SPATIAL"] + + static func createSchema(_ name: String, capabilities: PostgreSQLCapabilities) -> String { + let identifier = PostgreSQLObjectQueries.quoteIdentifier(name) + guard !capabilities.hasCreateSchemaIfNotExists else { + return "CREATE SCHEMA IF NOT EXISTS \(identifier)" + } + let nameLiteral = PostgreSQLObjectQueries.quoteLiteral(name) + let createLiteral = PostgreSQLObjectQueries.quoteLiteral("CREATE SCHEMA \(identifier)") + let body = "BEGIN IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = \(nameLiteral)) " + + "THEN EXECUTE \(createLiteral); END IF; END" + let tag = dollarQuoteTag(avoiding: body) + return "DO \(tag) \(body) \(tag)" + } + + static func reindexDatabase(currentDatabase: String?, capabilities: PostgreSQLCapabilities) -> String? { + guard !capabilities.hasUnnamedReindexDatabase else { return "REINDEX DATABASE CONCURRENTLY" } + guard let currentDatabase, !currentDatabase.isEmpty else { return nil } + let identifier = PostgreSQLObjectQueries.quoteIdentifier(currentDatabase) + guard capabilities.hasReindexConcurrently else { return "REINDEX DATABASE \(identifier)" } + return "REINDEX DATABASE CONCURRENTLY \(identifier)" + } + + static func triggerTemplate( + qualifiedTable: String, + qualifiedFunction: String, + capabilities: PostgreSQLCapabilities + ) -> String { + let trigger = PostgreSQLObjectQueries.quoteIdentifier("trigger_name") + let executeKeyword = capabilities.hasExecuteFunctionTriggerSyntax ? "EXECUTE FUNCTION" : "EXECUTE PROCEDURE" + let createKeyword = capabilities.hasCreateOrReplaceTrigger ? "CREATE OR REPLACE TRIGGER" : "CREATE TRIGGER" + return """ + CREATE OR REPLACE FUNCTION \(qualifiedFunction)() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + -- NEW.updated_at := now(); + RETURN NEW; + END; + $function$; + + \(createKeyword) \(trigger) + BEFORE INSERT ON \(qualifiedTable) + FOR EACH ROW + \(executeKeyword) \(qualifiedFunction)(); + """ + } + + static func editableTriggerDefinition( + functionDefinition: String, + triggerDefinition: String, + dropStatement: String?, + capabilities: PostgreSQLCapabilities + ) -> String { + guard capabilities.hasCreateOrReplaceTrigger else { + return "\(functionDefinition);\n\n\(triggerDefinition);" + } + guard triggerDefinition.range(of: "CREATE CONSTRAINT TRIGGER", options: .caseInsensitive) == nil else { + return "\(functionDefinition);\n\n\(dropStatement ?? "");\n\(triggerDefinition);" + } + let replaceable = triggerDefinition.replacingOccurrences( + of: "CREATE TRIGGER ", + with: "CREATE OR REPLACE TRIGGER " + ) + return "\(functionDefinition);\n\n\(replaceable);" + } + + static func renameConstraint( + qualifiedTable: String, + from oldName: String, + to newName: String, + capabilities: PostgreSQLCapabilities + ) -> String? { + guard capabilities.hasRenameConstraint, !oldName.isEmpty, !newName.isEmpty else { return nil } + return "ALTER TABLE \(qualifiedTable) RENAME CONSTRAINT " + + "\(PostgreSQLObjectQueries.quoteIdentifier(oldName)) TO \(PostgreSQLObjectQueries.quoteIdentifier(newName))" + } + + static func copyRows( + into qualifiedTable: String, + from stagingTable: String, + columnList: String, + capabilities: PostgreSQLCapabilities + ) -> String { + let overriding = capabilities.hasIdentityColumns ? " OVERRIDING SYSTEM VALUE" : "" + return "INSERT INTO \(qualifiedTable) (\(columnList))\(overriding) SELECT \(columnList) FROM \(stagingTable)" + } + + static func refusal(for operation: PluginSchemaOperation, capabilities: PostgreSQLCapabilities) -> String? { + switch operation { + case .addColumn(let column): + return columnRefusal(column, capabilities: capabilities) + case .addIndex(let index): + return indexRefusal(index, capabilities: capabilities) + case .renameCheckConstraint: + guard !capabilities.hasRenameConstraint else { return nil } + return String(localized: "Renaming a check constraint needs PostgreSQL 9.2 or later.") + @unknown default: + return nil + } + } + + static func refusal(for definition: PluginCreateTableDefinition, capabilities: PostgreSQLCapabilities) -> String? { + let operations = definition.columns.map(PluginSchemaOperation.addColumn) + + definition.indexes.map(PluginSchemaOperation.addIndex) + return operations.lazy.compactMap { refusal(for: $0, capabilities: capabilities) }.first + } + + static func columnRefusal(_ column: PluginColumnDefinition, capabilities: PostgreSQLCapabilities) -> String? { + guard column.isGenerated, !capabilities.hasGeneratedColumns else { return nil } + return String( + format: String(localized: "Column %@ is a generated column, which needs PostgreSQL 12 or later."), + column.name + ) + } + + static func indexRefusal(_ index: PluginIndexDefinition, capabilities: PostgreSQLCapabilities) -> String? { + guard let type = index.indexType?.uppercased(), !type.isEmpty else { return nil } + if mySQLOnlyIndexTypes.contains(type) { + return String(format: String(localized: "PostgreSQL has no %@ index type."), type) + } + guard type == "BRIN", !capabilities.hasBrinIndexes else { return nil } + return String(localized: "BRIN indexes need PostgreSQL 9.5 or later.") + } + + static func unsupportedStructureColumnFields(capabilities: PostgreSQLCapabilities) -> Set { + capabilities.hasGeneratedColumns ? [] : [.generated, .generationExpression] + } + + static func unsupportedIndexTypes(capabilities: PostgreSQLCapabilities) -> Set { + capabilities.hasBrinIndexes ? mySQLOnlyIndexTypes : mySQLOnlyIndexTypes.union(["BRIN"]) + } + + static func roleAttributes(capabilities: PostgreSQLCapabilities) -> Set { + Set(PostgreSQLRoleAttribute.allCases.filter { $0 != .bypassrls || capabilities.hasBypassRLS }) + } + + private static func dollarQuoteTag(avoiding body: String) -> String { + var label = "tablepro" + while body.contains("$\(label)$") { + label += "_" + } + return "$\(label)$" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewDefinition.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewDefinition.swift new file mode 100644 index 0000000000..f53ce9235e --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewDefinition.swift @@ -0,0 +1,164 @@ +// +// PostgreSQLViewDefinition.swift +// PostgreSQLDriverPlugin +// +// Rebuilds the CREATE statement of a view or materialized view from the catalog. Pure, so it is +// testable without a server. +// + +import Foundation + +public enum PostgreSQLViewDefinition { + public enum Kind: Equatable, Sendable { + case view + case materializedView + } + + /// Everything the statement is rebuilt from. `pg_get_viewdef` returns the query alone, so the + /// view's options, its check option, and a materialized view's access method and tablespace + /// each come from their own catalog column. + public struct CatalogRow: Equatable, Sendable { + public let kind: Kind + public let query: String + public let options: [String] + public let accessMethod: String? + public let tablespace: String? + + public init(kind: Kind, query: String, options: [String], accessMethod: String?, tablespace: String?) { + self.kind = kind + self.query = query + self.options = options + self.accessMethod = accessMethod + self.tablespace = tablespace + } + } + + /// Run with `search_path` narrowed first (see `qualifiedReadPrefix`). `pg_get_viewdef` writes a + /// table name bare whenever the reading session could resolve it without its schema, and the + /// scoped connection this runs on has its path set to the view's own schema, so the body came + /// back naming `orders` rather than `sales.orders`. Run anywhere else, that text silently bound + /// to whichever `orders` the new session found first. + public static func catalogQuery(name: String, schema: String) -> String { + """ + SELECT + c.relkind::text, + pg_catalog.pg_get_viewdef(c.oid, true), + c.reloptions::text, + am.amname::text, + ts.spcname::text + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_catalog.pg_am am ON am.oid = c.relam + LEFT JOIN pg_catalog.pg_tablespace ts ON ts.oid = c.reltablespace + WHERE c.relkind IN ('v', 'm') + AND n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND c.relname = \(PostgreSQLObjectQueries.quoteLiteral(name)) + """ + } + + /// `SET LOCAL` lasts only as long as the transaction it runs in. Sent in the same query string + /// as the read, the pair is one implicit transaction, so the path is back to what it was as + /// soon as the read returns. Inside a transaction the caller already holds, it would last until + /// that transaction ends, which is why the driver wraps the read in a savepoint there. + /// + /// `pg_catalog` rather than an empty path: PostgreSQL 9.1 rejects `''` ("schema "" does not + /// exist"), and `pg_catalog` is searched implicitly anyway, so every user relation is still + /// written with its schema. Measured identical output on 9.2, 9.3, 9.6 and 17. + public static let qualifiedReadPrefix = "SET LOCAL search_path = pg_catalog; " + + public static func parse(row: [String?]) -> CatalogRow? { + guard row.count >= 5, + let relkind = row[0], + let query = row[1], + let kind = kind(forRelkind: relkind) + else { return nil } + return CatalogRow( + kind: kind, + query: query, + options: PostgreSQLTextArray.values(row[2]), + accessMethod: row[3]?.nilIfBlank, + tablespace: row[4]?.nilIfBlank + ) + } + + public static func kind(forRelkind relkind: String) -> Kind? { + switch relkind { + case "v": return .view + case "m": return .materializedView + default: return nil + } + } + + public static func statement(name: String, schema: String, row: CatalogRow) -> String { + let target = PostgreSQLObjectQueries.qualifiedName(schema: schema, name: name) + let body = trimmedQuery(row.query) + switch row.kind { + case .view: + return viewStatement(target: target, body: body, options: row.options) + case .materializedView: + return materializedViewStatement(target: target, body: body, row: row) + } + } + + /// `CREATE OR REPLACE VIEW` resets every option the statement leaves out, so running a + /// definition that dropped `security_barrier`, `security_invoker` or the check option quietly + /// turned a restricted view into an unrestricted one. The check option is stored as a + /// reloption but written as its own clause after the query, the way `pg_dump` writes it. + private static func viewStatement(target: String, body: String, options: [String]) -> String { + let parsed = options.map(splitOption) + let checkOption = parsed.first { $0.name == "check_option" }?.value + let storage = parsed.filter { $0.name != "check_option" } + var header = "CREATE OR REPLACE VIEW \(target)" + if let withClause = withClause(storage) { + header += " \(withClause)" + } + var statement = "\(header) AS\n\(body)" + if let checkOption, !checkOption.isEmpty { + statement += "\n WITH \(checkOption.uppercased()) CHECK OPTION" + } + return statement + ";" + } + + /// The access method is written only when it is not the default, because `USING` does not + /// exist before PostgreSQL 12 and `heap` is what every server creates without it. + private static func materializedViewStatement(target: String, body: String, row: CatalogRow) -> String { + var header = "CREATE MATERIALIZED VIEW \(target)" + if let accessMethod = row.accessMethod, accessMethod != "heap" { + header += " USING \(PostgreSQLObjectQueries.quoteIdentifier(accessMethod))" + } + if let withClause = withClause(row.options.map(splitOption)) { + header += " \(withClause)" + } + if let tablespace = row.tablespace { + header += " TABLESPACE \(PostgreSQLObjectQueries.quoteIdentifier(tablespace))" + } + return "\(header) AS\n\(body);" + } + + private static func withClause(_ options: [(name: String, value: String)]) -> String? { + guard !options.isEmpty else { return nil } + let rendered = options.map { "\($0.name)=\(PostgreSQLObjectQueries.quoteLiteral($0.value))" } + return "WITH (\(rendered.joined(separator: ", ")))" + } + + private static func splitOption(_ option: String) -> (name: String, value: String) { + guard let separator = option.firstIndex(of: "=") else { return (option, "") } + return (String(option[.. String { + var trimmed = Substring(query) + while let last = trimmed.last, last.isWhitespace || last == ";" { + trimmed = trimmed.dropLast() + } + return String(trimmed) + } +} + +private extension String { + var nilIfBlank: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift index a26217dccb..63d4a1e3d0 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift @@ -21,30 +21,30 @@ enum RedshiftExternalSchemaQueries { /// Tables registered in one external schema. Both views carry rows for /// every database on the cluster, so the connected database is part of the - /// filter; two databases can each hold a schema of the same name. Literals - /// are escaped by the caller, matching the convention in + /// filter; two databases can each hold a schema of the same name. Names + /// arrive raw and are quoted here, matching the convention in /// RedshiftSchemaQueries. - static func listExternalTables(schemaLiteral: String, databaseLiteral: String) -> String { + static func listExternalTables(schema: String, database: String) -> String { """ SELECT tablename, tabletype FROM svv_external_tables - WHERE schemaname = '\(schemaLiteral)' - AND redshift_database_name = '\(databaseLiteral)' + WHERE schemaname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND redshift_database_name = \(PostgreSQLObjectQueries.quoteLiteral(database)) ORDER BY tablename """ } - /// Column introspection for one external schema. Passing `tableLiteral` - /// restricts the result to a single table; passing `nil` returns every - /// table's columns and prefixes each row with `tablename`. + /// Column introspection for one external schema. Passing `table` restricts + /// the result to a single table; passing `nil` returns every table's columns + /// and prefixes each row with `tablename`. static func listExternalColumns( - schemaLiteral: String, - tableLiteral: String?, - databaseLiteral: String + schema: String, + table: String?, + database: String ) -> String { - let selectPrefix = tableLiteral == nil ? "tablename,\n " : "" - let tableFilter = tableLiteral.map { " AND tablename = '\($0)'" } ?? "" - let orderBy = tableLiteral == nil ? "tablename, columnnum" : "columnnum" + let selectPrefix = table == nil ? "tablename,\n " : "" + let tableFilter = table.map { " AND tablename = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" + let orderBy = table == nil ? "tablename, columnnum" : "columnnum" return """ SELECT \(selectPrefix)columnname, @@ -52,8 +52,8 @@ enum RedshiftExternalSchemaQueries { is_nullable, part_key FROM svv_external_columns - WHERE schemaname = '\(schemaLiteral)'\(tableFilter) - AND redshift_database_name = '\(databaseLiteral)' + WHERE schemaname = \(PostgreSQLObjectQueries.quoteLiteral(schema))\(tableFilter) + AND redshift_database_name = \(PostgreSQLObjectQueries.quoteLiteral(database)) ORDER BY \(orderBy) """ } diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index a05d06cd87..30ef394303 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -75,11 +75,11 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let resolvedSchema = schema ?? core.currentSchema - let schemaLiteral = escapeLiteral(resolvedSchema) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(resolvedSchema) let query = """ SELECT table_name, table_type FROM information_schema.tables - WHERE table_schema = '\(schemaLiteral)' + WHERE table_schema = \(schemaLiteral) ORDER BY table_name """ let result = try await execute(query: query) @@ -92,7 +92,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { guard isExternalSchema(resolvedSchema) else { return localTables } - let externalTables = await fetchExternalTables(schemaLiteral: schemaLiteral, schema: resolvedSchema) + let externalTables = await fetchExternalTables(schema: resolvedSchema) guard !externalTables.isEmpty else { return localTables } let localNames = Set(localTables.map(\.name)) @@ -100,12 +100,12 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { return merged.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } - private func fetchExternalTables(schemaLiteral: String, schema: String) async -> [PluginTableInfo] { + private func fetchExternalTables(schema: String) async -> [PluginTableInfo] { do { let result = try await execute( query: RedshiftExternalSchemaQueries.listExternalTables( - schemaLiteral: schemaLiteral, - databaseLiteral: escapeLiteral(connectedDatabase) + schema: schema, + database: connectedDatabase ) ) return result.rows.compactMap { row -> PluginTableInfo? in @@ -128,27 +128,19 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { let resolvedSchema = schema ?? core.currentSchema if isExternalSchema(resolvedSchema) { - let external = await fetchExternalColumns( - schemaLiteral: escapeLiteral(resolvedSchema), - tableLiteral: escapeLiteral(table), - schema: resolvedSchema - ) + let external = await fetchExternalColumns(schema: resolvedSchema, table: table) if !external.isEmpty { return external } } return try await fetchLocalColumns(table: table, schema: resolvedSchema) } - private func fetchExternalColumns( - schemaLiteral: String, - tableLiteral: String, - schema: String - ) async -> [PluginColumnInfo] { + private func fetchExternalColumns(schema: String, table: String) async -> [PluginColumnInfo] { do { let result = try await execute( query: RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: schemaLiteral, - tableLiteral: tableLiteral, - databaseLiteral: escapeLiteral(connectedDatabase) + schema: schema, + table: table, + database: connectedDatabase ) ) return result.rows.compactMap { row -> PluginColumnInfo? in @@ -186,11 +178,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } private func fetchLocalColumns(table: String, schema: String) async throws -> [PluginColumnInfo] { - let schemaLiteral = escapeLiteral(schema) - let query = RedshiftSchemaQueries.columnsQuery( - schemaLiteral: schemaLiteral, - tableLiteral: escapeLiteral(table) - ) + let query = RedshiftSchemaQueries.columnsQuery(schema: schema, table: table) let result = try await execute(query: query) return result.rows.compactMap { row -> PluginColumnInfo? in guard row.count >= 4, @@ -236,25 +224,19 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let resolvedSchema = schema ?? core.currentSchema if isExternalSchema(resolvedSchema) { - let external = await fetchExternalAllColumns( - schemaLiteral: escapeLiteral(resolvedSchema), - schema: resolvedSchema - ) + let external = await fetchExternalAllColumns(schema: resolvedSchema) if !external.isEmpty { return external } } return try await fetchLocalAllColumns(schema: resolvedSchema) } - private func fetchExternalAllColumns( - schemaLiteral: String, - schema: String - ) async -> [String: [PluginColumnInfo]] { + private func fetchExternalAllColumns(schema: String) async -> [String: [PluginColumnInfo]] { do { let result = try await execute( query: RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: schemaLiteral, - tableLiteral: nil, - databaseLiteral: escapeLiteral(connectedDatabase) + schema: schema, + table: nil, + database: connectedDatabase ) ) var allColumns: [String: [PluginColumnInfo]] = [:] @@ -277,8 +259,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } private func fetchLocalAllColumns(schema: String) async throws -> [String: [PluginColumnInfo]] { - let schemaLiteral = escapeLiteral(schema) - let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: schemaLiteral, tableLiteral: nil) + let query = RedshiftSchemaQueries.columnsQuery(schema: schema, table: nil) let result = try await execute(query: query) var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { @@ -326,8 +307,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let safeTable = escapeLiteral(table) - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema) let query = """ SELECT "column", @@ -335,8 +316,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { distkey, sortkey FROM pg_table_def - WHERE schemaname = '\(schemaLiteral)' - AND tablename = '\(safeTable)' + WHERE schemaname = \(schemaLiteral) + AND tablename = \(tableLiteral) AND (distkey = true OR sortkey != 0) ORDER BY sortkey """ @@ -346,7 +327,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { var sortkeyCols: [String] = [] for row in result.rows { guard let colName = row[0].asText else { continue } - let isDistkey = row[2].asText == "t" + let isDistkey = PostgreSQLCatalogBoolean.isTrue(row[2].asText) let sortKeyVal = Int(row[3].asText ?? "0") ?? 0 if isDistkey { distkeyCols.append(colName) } if sortKeyVal != 0 { sortkeyCols.append(colName) } @@ -365,55 +346,27 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { var tableDDLIncludesForeignKeys: Bool { true } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { - let safeTable = escapeLiteral(table) - let query = """ - SELECT - tc.constraint_name, - kcu.column_name, - ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column, - rc.delete_rule, - rc.update_rule - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - JOIN information_schema.constraint_column_usage ccu - ON rc.unique_constraint_name = ccu.constraint_name - WHERE tc.table_name = '\(safeTable)' - AND tc.constraint_type = 'FOREIGN KEY' - ORDER BY tc.constraint_name - """ - let result = try await execute(query: query) - return result.rows.compactMap { row -> PluginForeignKeyInfo? in - guard row.count >= 6, - let name = row[0].asText, - let column = row[1].asText, - let refTable = row[2].asText, - let refColumn = row[3].asText - else { return nil } - return PluginForeignKeyInfo( - name: name, - column: column, - referencedTable: refTable, - referencedColumn: refColumn, - onDelete: row[4].asText ?? "NO ACTION", - onUpdate: row[5].asText ?? "NO ACTION" + let query = PostgreSQLCatalogForeignKeys.query( + schema: schema ?? core.currentSchema, + table: table, + excludesPartitionClones: PostgreSQLCatalogForeignKeys.excludesPartitionClones( + serverVersionNumber: core.serverVersionNumber ) - } + ) + let result = try await execute(query: query) + return PostgreSQLCatalogForeignKeys.foreignKeys(from: result.rows.map { $0.map(\.asText) }) } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { let resolvedSchema = schema ?? core.currentSchema guard !isExternalSchema(resolvedSchema) else { return nil } - let safeTable = escapeLiteral(table) - let schemaLiteral = escapeLiteral(resolvedSchema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(resolvedSchema) let query = """ SELECT tbl_rows FROM svv_table_info - WHERE "table" = '\(safeTable)' - AND schema = '\(schemaLiteral)' + WHERE "table" = \(tableLiteral) + AND schema = \(schemaLiteral) """ let result = try await execute(query: query) guard let firstRow = result.rows.first, let value = firstRow[0].asText, let count = Int(value) else { return nil } @@ -421,9 +374,9 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchTableDDL(table: String, schema: String?) async throws -> String { - let safeTable = escapeLiteral(table) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) let resolvedSchema = schema ?? core.currentSchema - let schemaLiteral = escapeLiteral(resolvedSchema) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(resolvedSchema) let quotedTable = quoteIdentifier(table) let quotedSchema = quoteIdentifier(resolvedSchema) @@ -445,8 +398,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum - WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(schemaLiteral)' + WHERE c.relname = \(tableLiteral) + AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum @@ -511,13 +464,13 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let safeView = escapeLiteral(view) - let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let viewLiteral = PostgreSQLObjectQueries.quoteLiteral(view) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema) let query = """ SELECT 'CREATE OR REPLACE VIEW ' || quote_ident(schemaname) || '.' || quote_ident(viewname) || ' AS ' || E'\\n' || definition AS ddl FROM pg_views - WHERE viewname = '\(safeView)' - AND schemaname = '\(schemaLiteral)' + WHERE viewname = \(viewLiteral) + AND schemaname = \(schemaLiteral) """ let result = try await execute(query: query) guard let firstRow = result.rows.first, let ddl = firstRow[0].asText else { @@ -531,8 +484,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { guard !isExternalSchema(resolvedSchema) else { return PluginTableMetadata(tableName: table, engine: "Redshift External") } - let safeTable = escapeLiteral(table) - let schemaLiteral = escapeLiteral(resolvedSchema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(resolvedSchema) let query = """ SELECT tbl_rows, @@ -541,8 +494,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { unsorted, stats_off FROM svv_table_info - WHERE "table" = '\(safeTable)' - AND schema = '\(schemaLiteral)' + WHERE "table" = \(tableLiteral) + AND schema = \(schemaLiteral) """ let result = try await execute(query: query) guard let row = result.rows.first else { @@ -580,12 +533,12 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - let escapedDbLiteral = escapeLiteral(database) + let databaseLiteral = PostgreSQLObjectQueries.quoteLiteral(database) let countQuery = """ SELECT COUNT(DISTINCT "table") AS table_count FROM svv_table_info WHERE schema NOT IN ('pg_internal', 'pg_catalog', 'information_schema') - AND database = '\(escapedDbLiteral)' + AND database = \(databaseLiteral) """ let sizeQuery = """ SELECT SUM(size) FROM svv_table_info WHERE database = current_database() @@ -679,7 +632,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { - let s = schema ?? currentSchema ?? "public" + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? currentSchema ?? "public") return """ SELECT schema, @@ -691,7 +644,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { unsorted, stats_off FROM svv_table_info - WHERE schema = '\(s)' + WHERE schema = \(schemaLiteral) ORDER BY "table" """ } diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift index 29d6cf52f9..f081a31dc1 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift @@ -10,13 +10,13 @@ import Foundation enum RedshiftSchemaQueries { - /// Column introspection for one schema. Passing `tableLiteral` restricts the - /// result to a single table; passing `nil` returns every table's columns and - /// prefixes each row with `table_name`. `schemaLiteral` is the only schema - /// source, so the caller resolves the target schema (qualified reference, - /// then current schema) before escaping and passing it here. - static func columnsQuery(schemaLiteral: String, tableLiteral: String?) -> String { - let shape = ColumnQueryShape.fragments(tableLiteral: tableLiteral) + /// Column introspection for one schema. Passing `table` restricts the result + /// to a single table; passing `nil` returns every table's columns and prefixes + /// each row with `table_name`. `schema` is the only schema source, so the + /// caller resolves the target schema (qualified reference, then current + /// schema) and passes it raw; quoting happens here. + static func columnsQuery(schema: String, table: String?) -> String { + let shape = ColumnQueryShape.fragments(table: table) return """ SELECT \(shape.selectPrefix)c.column_name, @@ -34,16 +34,8 @@ enum RedshiftSchemaQueries { LEFT JOIN pg_catalog.pg_description pgd ON pgd.objoid = cls.oid AND pgd.objsubid = c.ordinal_position - LEFT JOIN ( - SELECT DISTINCT \(shape.pkSelect) - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(schemaLiteral)'\(shape.pkTableFilter) - ) pk ON \(shape.pkJoin) - WHERE c.table_schema = '\(schemaLiteral)'\(shape.mainTableFilter) + \(ColumnQueryShape.primaryKeyJoin(schema: schema, fragments: shape)) + WHERE c.table_schema = \(PostgreSQLObjectQueries.quoteLiteral(schema))\(shape.mainTableFilter) ORDER BY \(shape.orderBy) """ } diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index 5f674c5304..095004d2c5 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index e867e0845b..62eb5de5da 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLExportPlugin/SQLExportEncodingDeclaration.swift b/Plugins/SQLExportPlugin/SQLExportEncodingDeclaration.swift new file mode 100644 index 0000000000..ec0e4acaa9 --- /dev/null +++ b/Plugins/SQLExportPlugin/SQLExportEncodingDeclaration.swift @@ -0,0 +1,57 @@ +// +// SQLExportEncodingDeclaration.swift +// SQLExportPlugin +// + +import Foundation +import TableProPluginKit + +internal struct SQLExportEncodingDeclaration: Equatable { + static let empty = SQLExportEncodingDeclaration(prologue: "", epilogue: "") + + let prologue: String + let epilogue: String + + private static let typesAcceptingSetClientEncoding: Set = [ + "PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite" + ] + + static func forDatabaseType(_ databaseTypeId: String) -> SQLExportEncodingDeclaration { + switch SqlDialect.from(databaseTypeId: databaseTypeId) { + case .mysql: + return mysql + case .postgres where typesAcceptingSetClientEncoding.contains(databaseTypeId): + return postgres + default: + return .empty + } + } + + private static let mysql = SQLExportEncodingDeclaration( + prologue: """ + /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; + /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; + /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; + /*!40101 SET NAMES utf8 */; + /*!50503 SET NAMES utf8mb4 */; + + + """, + epilogue: """ + + /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; + /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; + /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; + + """ + ) + + private static let postgres = SQLExportEncodingDeclaration( + prologue: """ + SET client_encoding = 'UTF8'; + + + """, + epilogue: "" + ) +} diff --git a/Plugins/SQLExportPlugin/SQLExportFileWriter.swift b/Plugins/SQLExportPlugin/SQLExportFileWriter.swift index a0673e562a..2afa774923 100644 --- a/Plugins/SQLExportPlugin/SQLExportFileWriter.swift +++ b/Plugins/SQLExportPlugin/SQLExportFileWriter.swift @@ -27,20 +27,33 @@ internal final class SQLExportFileWriter { private let destination: URL private let splitSizeBytes: Int + private let encodingDeclaration: SQLExportEncodingDeclaration private var handle: FileHandle private var tempURL: URL private var bytesInCurrentPart = 0 + private var currentPartHasStatements = false private var partIndex = 1 private var pending: [(temp: URL, final: URL)] = [] private var isCommitted = false - internal init(destination: URL, splitSizeMegabytes: Int) throws { + internal init( + destination: URL, + splitSizeMegabytes: Int, + encodingDeclaration: SQLExportEncodingDeclaration = .empty + ) throws { self.destination = destination self.splitSizeBytes = max(0, splitSizeMegabytes) * 1_024 * 1_024 + self.encodingDeclaration = encodingDeclaration let (handle, tempURL) = try PluginExportUtilities.beginAtomicWrite(for: destination) self.handle = handle self.tempURL = tempURL + do { + try writeRaw(encodingDeclaration.prologue) + } catch { + rollback() + throw error + } } /// True once a second part exists, so the caller can report the split rather than leaving the @@ -51,17 +64,20 @@ internal final class SQLExportFileWriter { internal func write(_ text: String) throws { let data = try text.toUTF8Data() - if splitSizeBytes > 0, bytesInCurrentPart > 0, bytesInCurrentPart + data.count > splitSizeBytes { + let partSize = bytesInCurrentPart + data.count + encodingDeclaration.epilogue.utf8.count + if splitSizeBytes > 0, currentPartHasStatements, partSize > splitSizeBytes { try rotate() } try handle.write(contentsOf: data) bytesInCurrentPart += data.count + currentPartHasStatements = true } /// Publishes every part and returns where they landed. An unsplit export keeps the name the /// user chose; a split one numbers all of its parts, so no part silently claims that name. @discardableResult internal func commit() throws -> [URL] { + try writeRaw(encodingDeclaration.epilogue) try handle.close() let finalURL = didSplit ? Self.partURL(for: destination, part: partIndex) : destination pending.append((tempURL, finalURL)) @@ -88,6 +104,7 @@ internal final class SQLExportFileWriter { internal var currentFileURL: URL { tempURL } private func rotate() throws { + try writeRaw(encodingDeclaration.epilogue) try handle.close() pending.append((tempURL, Self.partURL(for: destination, part: partIndex))) partIndex += 1 @@ -95,5 +112,14 @@ internal final class SQLExportFileWriter { handle = nextHandle tempURL = nextTemp bytesInCurrentPart = 0 + currentPartHasStatements = false + try writeRaw(encodingDeclaration.prologue) + } + + private func writeRaw(_ text: String) throws { + guard !text.isEmpty else { return } + let data = try text.toUTF8Data() + try handle.write(contentsOf: data) + bytesInCurrentPart += data.count } } diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 9feba46ef5..5219d8fb4d 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -54,6 +54,10 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send /// a table whose `CREATE TABLE` came back fine and only lost its indexes is a different thing /// to tell the user about. var indexFailures: [String] = [] + + /// Kept apart for the same reason `indexFailures` is: an object whose definition came back fine + /// and only lost its comments is a different thing to tell the user about. + var commentFailures: [String] = [] var metadataWarnings: [String] = [] /// The tables a foreign key cycle left the ordering unable to place. They keep the order the @@ -114,6 +118,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send ) async throws -> ExportFormatResult { ddlFailures = [] indexFailures = [] + commentFailures = [] metadataWarnings = [] exportSpansContainers = false tablesUnorderedByCycle = [] @@ -139,7 +144,11 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send metadataWarnings.append(String(localized: "A compressed export is written as one file, so the split size was not applied.")) } - let writer = try SQLExportFileWriter(destination: actualDestination, splitSizeMegabytes: splitSize) + let writer = try SQLExportFileWriter( + destination: actualDestination, + splitSizeMegabytes: splitSize, + encodingDeclaration: .forDatabaseType(dataSource.databaseTypeId) + ) var committed = false defer { if !committed { writer.rollback() } @@ -240,6 +249,11 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send format: String(localized: "Could not fetch indexes for: %@"), indexFailures.joined(separator: ", "))) } + if !commentFailures.isEmpty { + warnings.append(String( + format: String(localized: "Could not fetch comments for: %@"), + commentFailures.joined(separator: ", "))) + } warnings.append(contentsOf: metadataWarnings) return ExportFormatResult(warnings: warnings) } @@ -478,17 +492,58 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send guard !ddl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw SQLExportObjectError.emptyDefinition } - try writer.write(ddl) - if !ddl.hasSuffix(";") { - try writer.write(";") - } + try writer.write(ddl.hasSuffix(";") ? ddl : ddl + ";") try writer.write("\n\n") } catch { ddlFailures.append(sanitizedName) let ddlWarning = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" Self.logger.warning("Failed to fetch DDL for table \(sanitizedName): \(error)") try writer.write("-- \(PluginExportUtilities.sanitizeForSQLComment(ddlWarning))\n\n") + continue } + try await writeComments(for: table, dataSource: dataSource, to: writer) + } + } + + /// The kinds whose `CREATE` this dump writes as the object the driver's `COMMENT` keyword names. + /// A routine, trigger, sequence or type is skipped rather than asked, so a dump of one costs no + /// round trip. + /// + /// A foreign table is left out for a harder reason: the dump writes `CREATE TABLE` for it, so + /// PostgreSQL's own `COMMENT ON FOREIGN TABLE` fails the restore with `"f_orders" is not a + /// foreign table`. Measured on PostgreSQL 17.11. Add it back once a foreign table's `CREATE` is + /// its own. + private static let commentedKinds: Set = [ + .table, .view, .materializedView + ] + + /// Writes an object's comments directly after its own `CREATE`, which is where `pg_dump` puts + /// them, so a comment travels with the object it belongs to rather than with a later phase. + /// + /// Only ever called on the success branch: a `COMMENT` on an object whose `CREATE` was not + /// written fails the restore. An unreadable comment list is recorded and commented into the file + /// rather than failing the export, exactly as the index phase does. + private func writeComments( + for object: PluginExportTable, + dataSource: any PluginExportDataSource, + to writer: SQLExportFileWriter + ) async throws { + guard Self.commentedKinds.contains(object.kind) else { return } + let sanitizedName = PluginExportUtilities.sanitizeForSQLComment(object.name) + do { + let statements = try await dataSource.fetchCommentDDL( + table: object.name, databaseName: object.databaseName) + guard !statements.isEmpty else { return } + for statement in statements { + let terminated = statement.hasSuffix(";") ? statement : "\(statement);" + try writer.write("\(terminated)\n") + } + try writer.write("\n") + } catch { + commentFailures.append(sanitizedName) + Self.logger.warning("Failed to fetch comments for \(sanitizedName): \(error)") + let warning = "Warning: failed to fetch comments for \(sanitizedName): \(error)" + try writer.write("-- \(PluginExportUtilities.sanitizeForSQLComment(warning))\n\n") } } @@ -524,17 +579,16 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send guard !ddl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw SQLExportObjectError.emptyDefinition } - try writer.write(ddl) - if !ddl.hasSuffix(";") { - try writer.write(";") - } + try writer.write(ddl.hasSuffix(";") ? ddl : ddl + ";") try writer.write("\n\n") } catch { ddlFailures.append(sanitizedName) Self.logger.warning("Failed to fetch DDL for \(sanitizedName): \(error)") let warning = "Warning: failed to fetch definition for \(label.lowercased()) \(sanitizedName): \(error)" try writer.write("-- \(PluginExportUtilities.sanitizeForSQLComment(warning))\n\n") + continue } + try await writeComments(for: object, dataSource: dataSource, to: writer) } } diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index a8fc462857..851ff49322 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 6f3dc93304..cbba27e09b 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/SQLiteDriverPlugin/SQLiteMaintenance.swift b/Plugins/SQLiteDriverPlugin/SQLiteMaintenance.swift new file mode 100644 index 0000000000..ec4c34964a --- /dev/null +++ b/Plugins/SQLiteDriverPlugin/SQLiteMaintenance.swift @@ -0,0 +1,50 @@ +// +// SQLiteMaintenance.swift +// SQLiteDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// SQLite's maintenance operations and the statements they produce. +/// +/// Pure, so the confirmation sheet's preview and the statement that runs are one function rather than +/// two implementations that drifted: the sheet printed `VACUUM orders` where `VACUUM` ran, and +/// `Integrity Check orders` where `PRAGMA integrity_check` ran. +/// +/// Probed against SQLite 3.54.0. `VACUUM` and `PRAGMA integrity_check` act on the whole database and +/// name no object, which is why they are `.database`: the table the sheet printed beside them was +/// never in the statement. `ANALYZE` and `REINDEX` accept an object, and on a view both succeed and +/// do nothing, `ANALYZE "vw"` writing no `sqlite_stat1` row at all, so a view is not in their kind +/// sets. There is no kind error to go on here: a name SQLite does not recognise as a table is a +/// silent no-op reported as success. +nonisolated internal enum SQLiteMaintenance { + internal static let vacuum = "VACUUM" + internal static let analyze = "ANALYZE" + internal static let reindex = "REINDEX" + /// Not localized: it is the operation's identity, switched on here and sent to MCP clients as the + /// name they pass back. + internal static let integrityCheck = "Integrity Check" + + internal static let operations: [PluginMaintenanceOperation] = [ + PluginMaintenanceOperation(name: vacuum, appliesTo: [], scope: .database), + PluginMaintenanceOperation(name: analyze, appliesTo: [.table], scope: .objectOrDatabase), + PluginMaintenanceOperation(name: reindex, appliesTo: [.table], scope: .objectOrDatabase), + PluginMaintenanceOperation(name: integrityCheck, appliesTo: [], scope: .database) + ] + + internal static func statements(operation: String, table: String?) -> [String]? { + switch operation { + case vacuum: + return [vacuum] + case analyze: + return [table.map { "ANALYZE \(sqliteQuoteIdentifier($0))" } ?? "ANALYZE"] + case reindex: + return [table.map { "REINDEX \(sqliteQuoteIdentifier($0))" } ?? "REINDEX"] + case integrityCheck: + return ["PRAGMA integrity_check"] + default: + return nil + } + } +} diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 7ba6a91c89..fc5ae1ff65 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -571,8 +571,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func quoteIdentifier(_ name: String) -> String { - let escaped = name.replacingOccurrences(of: "`", with: "``") - return "`\(escaped)`" + sqliteQuoteIdentifier(name) } init(config: DriverConnectionConfig) { @@ -656,17 +655,15 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Maintenance func supportedMaintenanceOperations() -> [String]? { - ["VACUUM", "ANALYZE", "REINDEX", "Integrity Check"] + SQLiteMaintenance.operations.map(\.name) + } + + func maintenanceOperations() -> [PluginMaintenanceOperation]? { + SQLiteMaintenance.operations } func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { - switch operation { - case "VACUUM": return ["VACUUM"] - case "ANALYZE": return table.map { ["ANALYZE \(quoteIdentifier($0))"] } ?? ["ANALYZE"] - case "REINDEX": return table.map { ["REINDEX \(quoteIdentifier($0))"] } ?? ["REINDEX"] - case "Integrity Check": return ["PRAGMA integrity_check"] - default: return nil - } + SQLiteMaintenance.statements(operation: operation, table: table) } // MARK: - View Templates diff --git a/Plugins/SnowflakeDriverPlugin/Info.plist b/Plugins/SnowflakeDriverPlugin/Info.plist index fe6251ddc5..97dcecf8c0 100644 --- a/Plugins/SnowflakeDriverPlugin/Info.plist +++ b/Plugins/SnowflakeDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.48.0 TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/SpannerDriverPlugin/Info.plist b/Plugins/SpannerDriverPlugin/Info.plist index f39f4e4f74..3f8b6ffdc3 100644 --- a/Plugins/SpannerDriverPlugin/Info.plist +++ b/Plugins/SpannerDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds Spanner diff --git a/Plugins/SurrealDBDriverPlugin/Info.plist b/Plugins/SurrealDBDriverPlugin/Info.plist index 6b07b3e92d..58bac760c2 100644 --- a/Plugins/SurrealDBDriverPlugin/Info.plist +++ b/Plugins/SurrealDBDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds SurrealDB diff --git a/Plugins/TableProPluginKit/ClickHouseResponseClassifier.swift b/Plugins/TableProPluginKit/ClickHouseResponseClassifier.swift index fb60bc6624..9cd46409fa 100644 --- a/Plugins/TableProPluginKit/ClickHouseResponseClassifier.swift +++ b/Plugins/TableProPluginKit/ClickHouseResponseClassifier.swift @@ -37,11 +37,11 @@ public enum ClickHouseResponseClassifier { if let format = headerValue(headers, named: formatHeaderName), format != requestedFormat { return rawOutcome(body: body) } - let text = decodedText(body) - guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let bytes = [UInt8](body) + guard !ClickHouseTabSeparatedBytes.isAsciiWhitespace(bytes) else { return noResultSetOutcome(headers: headers) } - let lines = text.components(separatedBy: "\n") + let lines = ClickHouseTabSeparatedBytes.lines(bytes) guard lines.count >= 2 else { return rawOutcome(body: body) } @@ -58,30 +58,8 @@ public enum ClickHouseResponseClassifier { } public static func unescapeTsvField(_ field: String) -> String { - var result = "" - result.reserveCapacity((field as NSString).length) - var iterator = field.makeIterator() - - while let char = iterator.next() { - if char == "\\" { - if let next = iterator.next() { - switch next { - case "\\": result.append("\\") - case "t": result.append("\t") - case "n": result.append("\n") - default: - result.append("\\") - result.append(next) - } - } else { - result.append("\\") - } - } else { - result.append(char) - } - } - - return result + let bytes = Array(field.utf8) + return String(decoding: ClickHouseTabSeparatedBytes.unescape(bytes[...]), as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion } private static func noResultSetOutcome(headers: [String: String]) -> Outcome { @@ -94,31 +72,49 @@ public enum ClickHouseResponseClassifier { ) } + /// A body the server wrote in a format the user asked for is one opaque value. It is text when + /// it decodes as text and bytes when it does not, because a `Native` or `Parquet` body read as + /// Latin-1 is mojibake that cannot be copied back out. private static func rawOutcome(body: Data) -> Outcome { let isTruncated = body.count > rawBodyByteCap - let text = decodedText(body.prefix(rawBodyByteCap)) + let capped = [UInt8](body.prefix(rawBodyByteCap)) + let decodable = isTruncated ? Array(droppingCutSequence(capped)) : capped + let value: PluginCellValue = utf8Text(decodable).map { .text($0) } ?? .bytes(Data(capped)) return Outcome( columns: [String(localized: "Output")], columnTypeNames: ["String"], - rows: [[.text(text)]], + rows: [[value]], affectedRows: 1, isTruncated: isTruncated ) } - private static func tabSeparatedOutcome(lines: [String], rowLimit: Int) -> Outcome { - let columns = lines[0].components(separatedBy: "\t") - let columnTypeNames = lines[1].components(separatedBy: "\t") + private static func tabSeparatedOutcome(lines: [ArraySlice], rowLimit: Int) -> Outcome { + let columns = ClickHouseTabSeparatedBytes.fields(lines[0]).map(ClickHouseTabSeparatedBytes.headerText) + let columnTypeNames = ClickHouseTabSeparatedBytes.fields(lines[1]).map(ClickHouseTabSeparatedBytes.headerText) var rows: [[PluginCellValue]] = [] + var binaryColumns = Set() var isTruncated = false for index in 2..= rowLimit { @@ -130,19 +126,52 @@ public enum ClickHouseResponseClassifier { return Outcome( columns: columns, columnTypeNames: columnTypeNames, - rows: rows, + rows: demoteBinaryColumns(binaryColumns, in: rows), affectedRows: rows.count, isTruncated: isTruncated ) } - private static func decodedText(_ data: Data) -> String { - for suffixLength in 0...3 where data.count >= suffixLength { - if let text = String(bytes: data.dropLast(suffixLength), encoding: .utf8) { - return text + /// One value that is not text makes the whole column binary. A `FixedString(16)` of raw UUIDs + /// decodes on the rows whose bytes happen to be valid UTF-8 and not on the rest, and a column + /// that renders as hex on some rows and as mojibake on others is the worse answer. Re-encoding + /// a decoded value is exact: UTF-8 round-trips whenever the decode succeeded. + private static func demoteBinaryColumns( + _ binaryColumns: Set, + in rows: [[PluginCellValue]] + ) -> [[PluginCellValue]] { + guard !binaryColumns.isEmpty else { return rows } + return rows.map { row in + row.enumerated().map { column, value in + guard binaryColumns.contains(column), case .text(let text) = value else { return value } + return .bytes(Data(text.utf8)) + } + } + } + + private static func utf8Text(_ bytes: [UInt8]) -> String? { + String(bytes: bytes, encoding: .utf8) + } + + /// The byte cap can stop inside a multi-byte character, so a truncated body sheds that one + /// incomplete sequence and nothing else. Dropping trailing bytes until the rest decodes would + /// call a short binary body text, and a field is never cut, so it never comes through here. + private static func droppingCutSequence(_ bytes: [UInt8]) -> ArraySlice { + guard !bytes.isEmpty else { return bytes[...] } + for offset in 1...min(3, bytes.count) { + let index = bytes.count - offset + let byte = bytes[index] + if byte & 0xC0 == 0x80 { continue } + let sequenceLength: Int + switch byte { + case 0xC0...0xDF: sequenceLength = 2 + case 0xE0...0xEF: sequenceLength = 3 + case 0xF0...0xF7: sequenceLength = 4 + default: return bytes[...] } + return offset < sequenceLength ? bytes[.. String? { diff --git a/Plugins/TableProPluginKit/ClickHouseTabSeparatedBytes.swift b/Plugins/TableProPluginKit/ClickHouseTabSeparatedBytes.swift new file mode 100644 index 0000000000..a71f741181 --- /dev/null +++ b/Plugins/TableProPluginKit/ClickHouseTabSeparatedBytes.swift @@ -0,0 +1,98 @@ +import Foundation + +/// ClickHouse writes `TabSeparated` as a byte stream, not as text. A `String` column is an +/// arbitrary byte sequence, and the only bytes the format escapes are the eight below, so a value +/// holding a hash, a protobuf or a lone `0xC3` reaches the client verbatim. Splitting and +/// unescaping therefore happen on bytes, and only a finished field is offered to a text decode. +internal enum ClickHouseTabSeparatedBytes { + static let lineSeparator: UInt8 = 0x0A + static let fieldSeparator: UInt8 = 0x09 + private static let backslash: UInt8 = 0x5C + private static let nullMarkerSuffix: UInt8 = 0x4E + + static func lines(_ bytes: [UInt8]) -> [ArraySlice] { + split(bytes[...], on: lineSeparator) + } + + static func fields(_ line: ArraySlice) -> [ArraySlice] { + split(line, on: fieldSeparator) + } + + static func isNullMarker(_ field: ArraySlice) -> Bool { + field.count == 2 && field.first == backslash && field.last == nullMarkerSuffix + } + + static func isAsciiWhitespace(_ bytes: [UInt8]) -> Bool { + bytes.allSatisfy { $0 == 0x20 || $0 == 0x09 || $0 == 0x0A || $0 == 0x0D || $0 == 0x0B || $0 == 0x0C } + } + + /// The escape table ClickHouse's own `writeEscapedString` emits. An unrecognised escape keeps + /// both of its bytes: a future addition then reads oddly rather than losing the character. + static func unescape(_ field: ArraySlice) -> [UInt8] { + guard field.contains(backslash) else { return Array(field) } + + var result: [UInt8] = [] + result.reserveCapacity(field.count) + var index = field.startIndex + + while index < field.endIndex { + let byte = field[index] + guard byte == backslash else { + result.append(byte) + index = field.index(after: index) + continue + } + let next = field.index(after: index) + guard next < field.endIndex else { + result.append(backslash) + break + } + if let decoded = escapedByte(field[next]) { + result.append(decoded) + } else { + result.append(backslash) + result.append(field[next]) + } + index = field.index(after: next) + } + + return result + } + + /// Nil when the byte after the backslash is not one ClickHouse escapes. + static func escapedByte(_ byte: UInt8) -> UInt8? { + switch byte { + case 0x5C: return 0x5C + case 0x74: return 0x09 + case 0x6E: return 0x0A + case 0x72: return 0x0D + case 0x30: return 0x00 + case 0x62: return 0x08 + case 0x66: return 0x0C + case 0x27: return 0x27 + default: return nil + } + } + + /// A header field has to become a `String`, so an undecodable byte is replaced rather than + /// dropping the column. A value never takes this path; it stays bytes instead. + static func headerText(_ field: ArraySlice) -> String { + String(decoding: unescape(field), as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + } + + private static func split(_ bytes: ArraySlice, on separator: UInt8) -> [ArraySlice] { + var parts: [ArraySlice] = [] + var start = bytes.startIndex + var index = bytes.startIndex + + while index < bytes.endIndex { + if bytes[index] == separator { + parts.append(bytes[start.. [[PluginCellValue]] { + guard !data.isEmpty else { return [] } + + var buffer = pendingBytes + pendingBytes = [] + buffer.append(contentsOf: data) + + var rows: [[PluginCellValue]] = [] + var lineStart = 0 + for index in buffer.indices where buffer[index] == ClickHouseTabSeparatedBytes.lineSeparator { + if let row = ingest(buffer[lineStart.. [[PluginCellValue]] { + let buffer = pendingBytes + pendingBytes = [] + guard !buffer.isEmpty, let row = ingest(buffer[...]) else { return [] } + return [row] + } + + private mutating func ingest(_ line: ArraySlice) -> [PluginCellValue]? { + guard let columnNames else { + self.columnNames = ClickHouseTabSeparatedBytes.fields(line).map(ClickHouseTabSeparatedBytes.headerText) + return nil + } + guard header != nil else { + header = Header( + columns: columnNames, + columnTypeNames: ClickHouseTabSeparatedBytes.fields(line).map(ClickHouseTabSeparatedBytes.headerText) + ) + return nil + } + guard !line.isEmpty else { return nil } + return ClickHouseTabSeparatedBytes.fields(line).map(Self.cellValue) + } + + private static func cellValue(_ field: ArraySlice) -> PluginCellValue { + guard !ClickHouseTabSeparatedBytes.isNullMarker(field) else { return .null } + let value = ClickHouseTabSeparatedBytes.unescape(field) + guard let text = String(bytes: value, encoding: .utf8) else { return .bytes(Data(value)) } + return .text(text) + } +} diff --git a/Plugins/TableProPluginKit/PluginConcurrentRefreshAvailability.swift b/Plugins/TableProPluginKit/PluginConcurrentRefreshAvailability.swift new file mode 100644 index 0000000000..c097e3b1b1 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginConcurrentRefreshAvailability.swift @@ -0,0 +1,17 @@ +// +// PluginConcurrentRefreshAvailability.swift +// TableProPluginKit +// + +import Foundation + +/// Whether one materialized view can be refreshed without blocking the sessions reading it. +/// +/// The answer depends on the view, not only on the engine: PostgreSQL refuses a concurrent refresh +/// of a view that has no usable unique index or has never been populated, and the prompt says which +/// rather than offering an option the server will reject. +public enum PluginConcurrentRefreshAvailability: String, Sendable, Equatable { + case available + case requiresUniqueIndex + case requiresPopulatedView +} diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 98ff627810..d518214ff2 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -112,6 +112,19 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// here must not also declare the same indexes in `fetchTableDDL`, or the dump creates each /// one twice. func fetchIndexDDL(table: String, schema: String?) async throws -> [String] + + /// The `COMMENT` statements that reattach this relation's own comment and its column comments, + /// ready to run. A dump writes them directly after the object's `CREATE`, which is where every + /// engine's own tool puts them. + /// + /// This is statement text rather than the comment strings, because only the driver knows the + /// keyword its engine demands: PostgreSQL checks `COMMENT ON TABLE` against the relation's + /// `relkind` and refuses it on a view with "is not a table". + /// + /// Returning nothing is the right answer for an engine whose `CREATE TABLE` carries its comments + /// inline. A driver that answers here must not also declare the same comments in + /// `fetchTableDDL`, or the dump sets each one twice. + func fetchCommentDDL(table: String, schema: String?) async throws -> [String] func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] func fetchCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] @@ -177,6 +190,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// it and say so. Throwing is reserved for a release that was attempted and failed. func releaseIdleResource() async throws -> PluginResourceRelease var serverVersion: String? { get } + var hasLostConnection: Bool { get } var parameterStyle: ParameterStyle { get } func resolveQueryCompletionProfile( databaseTypeId: String, @@ -315,6 +329,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// answer, which stands the check down for an engine TablePro never runs a rebuild on anyway. func columnReorderSchemaFingerprint(table: String, schema: String?) async throws -> String? + var unsupportedStructureColumnFields: Set { get } + var unsupportedIndexTypes: Set { get } + func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? // Definition SQL for clipboard copy (optional — return nil if not supported) @@ -337,10 +355,36 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// namespaces out and say so rather than emitting DDL the server will reject. func createSchemaStatement(name: String) -> String? + /// Sets or clears the comment on a table-like object. `objectType` is the object's type as the + /// table listing reported it, because engines that key the statement on the kind refuse the + /// wrong keyword: PostgreSQL answers `COMMENT ON TABLE` on a view with "is not a table". A nil + /// or empty comment removes it. Return nil for a kind the engine cannot comment on. + func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? + + /// The statement that recomputes a materialized view's stored rows. Return nil where the engine + /// has no materialized views or keeps them current on its own. + func refreshMaterializedViewStatement(name: String, schema: String?, concurrently: Bool) -> String? + + /// Whether this view can be refreshed without blocking its readers. Return nil where the engine + /// has no such refresh, so the option is not offered at all. + func concurrentRefreshAvailability( + materializedView: String, + schema: String? + ) async throws -> PluginConcurrentRefreshAvailability? + // Maintenance operations (optional — return nil if not supported) func supportedMaintenanceOperations() -> [String]? func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? + /// The maintenance operations this driver offers, each carrying the object kinds it may name, the + /// scope its statement has and the options it reads. Return nil where the engine has no + /// maintenance at all. + /// + /// `supportedMaintenanceOperations()` answers bare names, which cannot say any of that, so the + /// app offered every operation on every object and hand-wrote its own preview of the SQL. A + /// driver that answers this one is the single source of both. + func maintenanceOperations() -> [PluginMaintenanceOperation]? + // EXPLAIN query building (optional) func buildExplainQuery(_ sql: String) -> String? @@ -542,6 +586,8 @@ public extension PluginDatabaseDriver { var serverVersion: String? { nil } + var hasLostConnection: Bool { false } + var parameterStyle: ParameterStyle { .questionMark } func resolveQueryCompletionProfile( @@ -613,6 +659,11 @@ public extension PluginDatabaseDriver { /// statements from `fetchTableDDL` in the same change. func fetchIndexDDL(table: String, schema: String?) async throws -> [String] { [] } + /// Defaults to nothing, which is correct for an engine whose `CREATE TABLE` already carries its + /// comments and for one that stores none. A driver that overrides this must drop the same + /// statements from `fetchTableDDL` in the same change. + func fetchCommentDDL(table: String, schema: String?) async throws -> [String] { [] } + /// Answers whether `fetchAllIndexes` is a single query rather than the N+1 default below. var providesBulkIndexFetch: Bool { false } @@ -765,6 +816,10 @@ public extension PluginDatabaseDriver { func columnReorderSchemaFingerprint(table: String, schema: String?) async throws -> String? { nil } + var unsupportedStructureColumnFields: Set { [] } + var unsupportedIndexTypes: Set { [] } + func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { nil } + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { nil } func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { nil } @@ -777,9 +832,29 @@ public extension PluginDatabaseDriver { func foreignKeyEnableStatements() -> [String]? { nil } func createSchemaStatement(name: String) -> String? { nil } + func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { + nil + } + + func refreshMaterializedViewStatement(name: String, schema: String?, concurrently: Bool) -> String? { nil } + + func concurrentRefreshAvailability( + materializedView: String, + schema: String? + ) async throws -> PluginConcurrentRefreshAvailability? { + nil + } + func supportedMaintenanceOperations() -> [String]? { nil } func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { nil } + /// Lifts a driver that answers only the older name list into descriptors, so an already-built + /// plugin keeps exactly the behaviour it had. `PluginMaintenanceOperation.lifting` says what that + /// is, and is where the rule is asserted. + func maintenanceOperations() -> [PluginMaintenanceOperation]? { + supportedMaintenanceOperations().map(PluginMaintenanceOperation.lifting) + } + func buildExplainQuery(_ sql: String) -> String? { nil } func injectRowLimit(_ sql: String, limit: Int) -> String? { nil } diff --git a/Plugins/TableProPluginKit/PluginExportDataSource.swift b/Plugins/TableProPluginKit/PluginExportDataSource.swift index ad82492825..3eb3afdf0c 100644 --- a/Plugins/TableProPluginKit/PluginExportDataSource.swift +++ b/Plugins/TableProPluginKit/PluginExportDataSource.swift @@ -20,6 +20,11 @@ public protocol PluginExportDataSource: AnyObject, Sendable { /// statements this table needs that `fetchTableDDL` does not already declare. func fetchIndexDDL(table: String, databaseName: String) async throws -> [String] + /// Mirrors `PluginDatabaseDriver.fetchCommentDDL` for the export side: the `COMMENT` statements + /// that reattach this relation's comment and its column comments, which `fetchTableDDL` does not + /// declare. + func fetchCommentDDL(table: String, databaseName: String) async throws -> [String] + /// The CREATE statement for any exportable object, routines, triggers, views and user types /// included. One method rather than one per kind, because the caller already knows the kind and /// every driver answers the same question: what would recreate this. @@ -61,6 +66,8 @@ public extension PluginExportDataSource { func fetchIndexDDL(table: String, databaseName: String) async throws -> [String] { [] } + func fetchCommentDDL(table: String, databaseName: String) async throws -> [String] { [] } + func fetchObjectDDL(_ object: PluginExportTable) async throws -> String { try await fetchTableDDL(table: object.name, databaseName: object.databaseName) } diff --git a/Plugins/TableProPluginKit/PluginMaintenanceOperation.swift b/Plugins/TableProPluginKit/PluginMaintenanceOperation.swift new file mode 100644 index 0000000000..e2c1239584 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginMaintenanceOperation.swift @@ -0,0 +1,159 @@ +// +// PluginMaintenanceOperation.swift +// TableProPluginKit +// + +import Foundation + +/// The kind of object a maintenance operation may name, spelled as `PluginTableInfo.type` is. +/// +/// A struct rather than an enum, for the reason `DatabaseType` is one: a driver may report a kind +/// this framework never heard of, and `PluginDriverAdapter` already logs and falls back for exactly +/// that. An unknown spelling has to compare unequal to every known kind rather than fail to exist. +/// +/// The raw value is uppercased on the way in so a driver that answers `"view"` and one that answers +/// `"VIEW"` land on the same kind. +public struct PluginObjectKind: RawRepresentable, Hashable, Sendable, Codable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue.uppercased() + } + + public init(_ name: String) { + self.init(rawValue: name) + } + + public static let table = PluginObjectKind(rawValue: "TABLE") + public static let partitionedTable = PluginObjectKind(rawValue: "PARTITIONED TABLE") + public static let view = PluginObjectKind(rawValue: "VIEW") + public static let materializedView = PluginObjectKind(rawValue: "MATERIALIZED VIEW") + public static let foreignTable = PluginObjectKind(rawValue: "FOREIGN TABLE") + public static let systemTable = PluginObjectKind(rawValue: "SYSTEM TABLE") + public static let externalTable = PluginObjectKind(rawValue: "EXTERNAL TABLE") + + /// Every kind the object browser lists as a table row. + /// + /// What a driver that answers only the older `supportedMaintenanceOperations()` inherits, so its + /// menu keeps exactly the shape it had before kinds existed. + public static let allTableLike: Set = [ + .table, + .partitionedTable, + .view, + .materializedView, + .foreignTable, + .systemTable, + .externalTable + ] +} + +/// What an operation's statement may name. +/// +/// SQLite's `VACUUM` and `PRAGMA integrity_check` take no object at all, so an operation list of +/// bare names cannot say that the table the sheet printed beside them was never in the statement. +public enum PluginMaintenanceScope: String, Sendable, Codable { + case object + case database + case objectOrDatabase + + /// Whether a statement for this operation may name one object. + public var admitsObject: Bool { + switch self { + case .object, .objectOrDatabase: return true + case .database: return false + } + } + + /// Whether a statement for this operation may name no object and act on the whole database. + public var admitsDatabase: Bool { + switch self { + case .database, .objectOrDatabase: return true + case .object: return false + } + } +} + +/// One option an operation accepts, as the driver that builds the statement declares it. +/// +/// Declared rather than assumed because the confirmation sheet used to decide this itself, gated on +/// two engine names: an engine that grew the same flags would not have been offered them, and the +/// keys the sheet produced had to agree by hand with the keys the driver read. +public struct PluginMaintenanceOption: Hashable, Sendable, Codable { + /// The key this option's value travels under in the `options` dictionary. + public let key: String + public let label: String + public let defaultValue: String + /// The fixed set of values, or nil for a boolean the caller sends as `"true"` or `"false"`. + public let choices: [String]? + + public init(key: String, label: String, defaultValue: String, choices: [String]? = nil) { + self.key = key + self.label = label + self.defaultValue = defaultValue + self.choices = choices + } + + public var isToggle: Bool { choices == nil } +} + +/// One maintenance operation, with everything the caller needs to decide whether to offer it. +/// +/// The older `supportedMaintenanceOperations()` answers a list of bare names, which cannot say which +/// objects an operation works on. PostgreSQL answers `VACUUM` on a view with a WARNING and the +/// success command tag `VACUUM`, so the app reported success over work the server skipped, while +/// `REINDEX` on the same view failed outright. +public struct PluginMaintenanceOperation: Hashable, Sendable, Codable { + public let name: String + public let appliesTo: Set + public let scope: PluginMaintenanceScope + public let options: [PluginMaintenanceOption] + + public init( + name: String, + appliesTo: Set, + scope: PluginMaintenanceScope, + options: [PluginMaintenanceOption] = [] + ) { + self.name = name + self.appliesTo = appliesTo + self.scope = scope + self.options = options + } + + /// Whether a statement for this operation may name an object of `kind`. + public func applies(to kind: PluginObjectKind) -> Bool { + scope.admitsObject && appliesTo.contains(kind) + } + + /// The object a statement for this operation should name, given the object it was reached from. + /// + /// Nil for an operation that acts on the whole database, so the object is dropped rather than + /// printed beside a statement that never mentions it: the sheet showed `VACUUM orders` on SQLite + /// where `VACUUM` ran. + public func target(_ objectName: String?) -> String? { + scope.admitsObject ? objectName : nil + } + + /// Every option's default, which is what a caller sends when the user changes nothing. + public var defaultOptionValues: [String: String] { + var values: [String: String] = [:] + for option in options { + values[option.key] = option.defaultValue + } + return values + } + + /// Lifts a driver's older list of bare names into descriptors. + /// + /// What `PluginDatabaseDriver.maintenanceOperations()` defaults to, so a plugin built before kinds + /// existed keeps exactly the behaviour it had: every table-like kind, either scope, no options. + public static func lifting(_ names: [String]) -> [PluginMaintenanceOperation] { + names.map { + PluginMaintenanceOperation( + name: $0, + appliesTo: PluginObjectKind.allTableLike, + scope: .objectOrDatabase + ) + } + } +} diff --git a/Plugins/TableProPluginKit/PluginSchemaOperation.swift b/Plugins/TableProPluginKit/PluginSchemaOperation.swift new file mode 100644 index 0000000000..30f9136d97 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginSchemaOperation.swift @@ -0,0 +1,12 @@ +// +// PluginSchemaOperation.swift +// TableProPluginKit +// + +import Foundation + +public enum PluginSchemaOperation: Sendable { + case addColumn(PluginColumnDefinition) + case addIndex(PluginIndexDefinition) + case renameCheckConstraint(from: String, to: String) +} diff --git a/Plugins/TableProPluginKit/SQLStatementSplitting.swift b/Plugins/TableProPluginKit/SQLStatementSplitting.swift index 1bf2e0dc6d..5b63ef7ce1 100644 --- a/Plugins/TableProPluginKit/SQLStatementSplitting.swift +++ b/Plugins/TableProPluginKit/SQLStatementSplitting.swift @@ -47,6 +47,12 @@ public enum SQLStatementSplitting { /// Everything before the first token that is not a comment. A statement is classified by its /// first word, and a comment in front of it is not that word. + /// + /// `/*!50601 ... */`, and MariaDB's `/*M!100301 ... */`, are not comments. MySQL parses the + /// body as SQL whenever the server is at least that version, and mysqldump writes its whole + /// preamble that way, so stripping them hid every `SET` a restore ran. They are left whole for + /// the driver that knows how to read them; to an engine that does treat them as comments the + /// statement is one it does not recognise, which is what an ignored comment already was. public static func stripLeadingComments(_ statement: String) -> String { var remainder = Substring(statement) while true { @@ -56,7 +62,7 @@ public enum SQLStatementSplitting { remainder = trimmed[trimmed.index(after: newline)...] continue } - if trimmed.hasPrefix("/*") { + if trimmed.hasPrefix("/*"), !isExecutableComment(trimmed) { guard let end = trimmed.range(of: "*/") else { return "" } remainder = trimmed[end.upperBound...] continue @@ -65,6 +71,10 @@ public enum SQLStatementSplitting { } } + private static func isExecutableComment(_ text: Substring) -> Bool { + text.hasPrefix("/*!") || text.hasPrefix("/*M!") + } + /// Tracks whether the scan currently sits inside something a `;` cannot end. private struct Scanner { private enum State { diff --git a/Plugins/TableProPluginKit/SqlDialect.swift b/Plugins/TableProPluginKit/SqlDialect.swift index 38d96b4abd..d600dcfdc9 100644 --- a/Plugins/TableProPluginKit/SqlDialect.swift +++ b/Plugins/TableProPluginKit/SqlDialect.swift @@ -10,7 +10,7 @@ public enum SqlDialect: String, Sendable, CaseIterable { switch databaseTypeId { case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite": return .postgres - case "MySQL", "MariaDB", "TiDB": + case "MySQL", "MariaDB", "TiDB", "OceanBase": return .mysql case "SQLite", "libSQL", "Turso", "DuckDB", "Cloudflare D1": return .sqlite diff --git a/Plugins/TeradataDriverPlugin/Info.plist b/Plugins/TeradataDriverPlugin/Info.plist index 0b0de66a10..da17a5a58e 100644 --- a/Plugins/TeradataDriverPlugin/Info.plist +++ b/Plugins/TeradataDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/TrinoDriverPlugin/Info.plist b/Plugins/TrinoDriverPlugin/Info.plist index 0b0de66a10..da17a5a58e 100644 --- a/Plugins/TrinoDriverPlugin/Info.plist +++ b/Plugins/TrinoDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 25 + 29 diff --git a/Plugins/TypesenseDriverPlugin/Info.plist b/Plugins/TypesenseDriverPlugin/Info.plist index 32b33c04ab..976e8f9ca5 100644 --- a/Plugins/TypesenseDriverPlugin/Info.plist +++ b/Plugins/TypesenseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 25 + 29 TableProProvidesDatabaseTypeIds Typesense diff --git a/Plugins/WeaviateDriverPlugin/Info.plist b/Plugins/WeaviateDriverPlugin/Info.plist new file mode 100644 index 0000000000..b30794dd75 --- /dev/null +++ b/Plugins/WeaviateDriverPlugin/Info.plist @@ -0,0 +1,14 @@ + + + + + TableProMinAppVersion + 0.73.0 + TableProPluginKitVersion + 29 + TableProProvidesDatabaseTypeIds + + Weaviate + + + diff --git a/Plugins/WeaviateDriverPlugin/WeaviatePlugin.swift b/Plugins/WeaviateDriverPlugin/WeaviatePlugin.swift new file mode 100644 index 0000000000..d533f77b68 --- /dev/null +++ b/Plugins/WeaviateDriverPlugin/WeaviatePlugin.swift @@ -0,0 +1,146 @@ +import Foundation +import TableProPluginKit +import TableProWeaviateCore + +final class WeaviatePlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Weaviate Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Weaviate support over the REST API with a GraphQL console" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Weaviate" + static let databaseDisplayName = "Weaviate" + static let iconName = "weaviate-icon" + static let defaultPort = WeaviateConnectionSettings.defaultPort + static let isDownloadable = true + + static let navigationModel: NavigationModel = .standard + static let pathFieldRole: PathFieldRole = .database + static let requiresAuthentication = false + static let brandColorHex = "#01B0D3" + static let queryLanguageName = "GraphQL" + static let editorLanguage: EditorLanguage = .javascript + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsDatabaseSwitching = false + static let supportsImport = false + static let supportsExport = true + static let supportsSSH = false + static let supportsSSL = true + static let supportsReadOnlyMode = true + static let supportsForeignKeyDisable = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + static let databaseGroupingStrategy: GroupingStrategy = .flat + static let defaultGroupName = "default" + static let tableEntityName = "Collections" + static let containerEntityName = "Cluster" + static let immutableColumns: [String] = WeaviateSchema.immutableColumns + static let defaultPrimaryKeyColumn: String? = WeaviateSchema.uuidColumn + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable] + static let sqlDialect: SQLDialectDescriptor? = nil + + static let columnTypesByCategory: [String: [String]] = weaviateColumnTypes + + static let additionalConnectionFields: [ConnectionField] = weaviateConnectionFields() + + static var statementCompletions: [CompletionEntry] { weaviateCompletions } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + WeaviatePluginDriver(config: config) + } +} + +func weaviateConnectionFields() -> [ConnectionField] { + [ + ConnectionField( + id: WeaviateFieldID.authMethod, + label: String(localized: "Auth Method"), + defaultValue: WeaviateAuthMethod.none.rawValue, + fieldType: .dropdown(options: [ + .init(value: WeaviateAuthMethod.none.rawValue, label: "None"), + .init(value: WeaviateAuthMethod.apiKey.rawValue, label: "API Key") + ]), + section: .authentication + ), + ConnectionField( + id: WeaviateFieldID.apiKey, + label: String(localized: "API Key"), + placeholder: "Weaviate API key", + fieldType: .secure, + section: .authentication, + hidesPassword: true + ).withHidesUsername(true), + ConnectionField( + id: WeaviateFieldID.skipTLSVerify, + label: String(localized: "Skip TLS Verification"), + defaultValue: "false", + fieldType: .toggle, + section: .advanced + ) + ] +} + +let weaviateCompletions: [CompletionEntry] = [ + CompletionEntry( + label: "Get", + insertText: """ + { + Get { + Article(limit: 10) { + title + _additional { id distance } + } + } + } + """ + ), + CompletionEntry( + label: "Near text", + insertText: """ + { + Get { + Article( + nearText: { concepts: ["search term"] } + limit: 10 + ) { + title + _additional { id distance } + } + } + } + """ + ), + CompletionEntry( + label: "Hybrid", + insertText: """ + { + Get { + Article( + hybrid: { query: "search term", alpha: 0.5 } + limit: 10 + ) { + title + _additional { id score } + } + } + } + """ + ), + CompletionEntry(label: "GET /v1/schema", insertText: "GET /v1/schema"), + CompletionEntry(label: "GET /v1/meta", insertText: "GET /v1/meta"), + CompletionEntry(label: "GET /v1/objects", insertText: "GET /v1/objects?class=Article&limit=10") +] + +let weaviateColumnTypes: [String: [String]] = [ + "Text": ["text", "text[]", "string", "string[]", "uuid", "uuid[]"], + "Numeric": ["int", "int[]", "number", "number[]"], + "Boolean": ["boolean", "boolean[]"], + "Date": ["date", "date[]"], + "Structured": ["object", "object[]", "geoCoordinates", "phoneNumber"], + "Vector": ["vector"] +] diff --git a/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver+Execution.swift b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver+Execution.swift new file mode 100644 index 0000000000..55632941d2 --- /dev/null +++ b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver+Execution.swift @@ -0,0 +1,202 @@ +import Foundation +import TableProPluginKit +import TableProWeaviateCore + +extension WeaviatePluginDriver { + func execute(query: String) async throws -> PluginQueryResult { + let started = Date() + let client = try requireClient() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.lowercased() == "select 1" { + try await client.ping() + return PluginQueryResult( + columns: ["ok"], + columnTypeNames: ["int"], + rows: [[.text("1")]], + rowsAffected: 0, + executionTime: Date().timeIntervalSince(started) + ) + } + + if WeaviateBrowseQuery.isTagged(trimmed) { + return try await executeSearch(trimmed, client: client, started: started) + } + if WeaviateWriteCodec.isTagged(trimmed) { + return try await executeWrite(trimmed, client: client, started: started) + } + if let console = WeaviateConsoleParser.parse(trimmed) { + return try await executeConsole(console, client: client, started: started) + } + if WeaviateGraphQL.looksLikeGraphQL(trimmed) { + return try await executeGraphQL(trimmed, client: client, started: started) + } + + throw WeaviateError.malformedResponse( + String(localized: "Enter a GraphQL query, or a request like GET /v1/schema.") + ) + } + + private func executeSearch( + _ query: String, + client: WeaviateClient, + started: Date + ) async throws -> PluginQueryResult { + guard let parsed = WeaviateBrowseQuery.parse(query) else { + throw WeaviateError.malformedResponse(String(localized: "Invalid browse request.")) + } + let collection = try await cachedCollection(parsed.collection) + let wantsVector = parsed.propertyNames.isEmpty + || parsed.propertyNames.contains(WeaviateSchema.vectorColumn) + let objects: [WeaviateObject] + if parsed.usesGraphQL { + let graphql = try WeaviateGraphQL.getQuery( + collection: parsed.collection, + properties: parsed.propertyNames, + limit: parsed.limit, + offset: parsed.offset, + sorts: parsed.sortableSorts, + filters: parsed.filters, + logicMode: parsed.logicMode, + schema: propertySchema(of: collection), + includeVector: wantsVector + ) + let response = try await client.graphql(graphql) + objects = WeaviateObjectCodec.objects(fromGraphQL: response.json as Any) + } else { + objects = try await client.objects( + collection: parsed.collection, + limit: parsed.limit, + offset: parsed.offset, + includeVector: wantsVector + ) + } + return render(objects: objects, collection: collection, columns: parsed.propertyNames, started: started) + } + + private func executeWrite( + _ statement: String, + client: WeaviateClient, + started: Date + ) async throws -> PluginQueryResult { + guard let request = WeaviateWriteCodec.decode(statement) else { + throw WeaviateError.malformedResponse(String(localized: "Invalid write request.")) + } + let response = try await client.execute(write: request) + let outcome: String + if let json = WeaviateJSON.dictionary(response.json), let id = json["id"] as? String { + outcome = id + } else if response.statusCode == 204 { + outcome = "deleted" + } else { + outcome = "ok" + } + return PluginQueryResult( + columns: ["result"], + columnTypeNames: ["text"], + rows: [[.text(outcome)]], + rowsAffected: 1, + executionTime: Date().timeIntervalSince(started) + ) + } + + private func executeConsole( + _ request: WeaviateConsoleRequest, + client: WeaviateClient, + started: Date + ) async throws -> PluginQueryResult { + if request.method == "POST", request.path.hasPrefix("/v1/graphql"), let body = request.body { + return try await executeGraphQL(body, client: client, started: started) + } + let response = try await client.execute(console: request) + if request.path.hasPrefix("/v1/objects"), let json = response.json { + let objects = WeaviateObject.parseList(json) + if !objects.isEmpty { + return await renderReturnedColumns(objects, started: started) + } + } + return renderJSON(response, started: started) + } + + private func executeGraphQL( + _ query: String, + client: WeaviateClient, + started: Date + ) async throws -> PluginQueryResult { + let response = try await client.graphql(query) + let objects = WeaviateObjectCodec.objects(fromGraphQL: response.json as Any) + if !objects.isEmpty { + return await renderReturnedColumns(objects, started: started) + } + return renderJSON(response, started: started) + } + + /// A console query selects its own fields, so the result shows what came back rather than every + /// column the collection has. That is also what carries `_additional { distance }` into the grid. + private func renderReturnedColumns(_ objects: [WeaviateObject], started: Date) async -> PluginQueryResult { + let collectionName = objects.first?.className ?? "" + let collection = (try? await cachedCollection(collectionName)) + ?? WeaviateCollection(name: collectionName, properties: []) + return render( + objects: objects, + collection: collection, + columns: returnedColumns(of: objects, collection: collection), + started: started + ) + } + + private func returnedColumns(of objects: [WeaviateObject], collection: WeaviateCollection) -> [String] { + let declared = collection.properties.map(\.name) + let returned = Set(objects.flatMap { $0.properties.keys }) + var columns: [String] = [] + if objects.contains(where: { !$0.uuid.isEmpty }) { + columns.append(WeaviateSchema.uuidColumn) + } + columns += declared.filter { returned.contains($0) } + columns += returned.subtracting(declared).sorted() + if objects.contains(where: { $0.vector != nil }) { + columns.append(WeaviateSchema.vectorColumn) + } + return columns.isEmpty ? [WeaviateSchema.uuidColumn] : columns + } + + private func render( + objects: [WeaviateObject], + collection: WeaviateCollection, + columns: [String], + started: Date + ) -> PluginQueryResult { + let resolved = columns.isEmpty + ? WeaviateSchema.columns(for: collection).map(\.name) + : columns + let rows = objects.map { object in + WeaviateObjectCodec.row(for: object, columns: resolved).map { value in + value.map(PluginCellValue.text) ?? .null + } + } + return PluginQueryResult( + columns: resolved, + columnTypeNames: resolved.map { typeName(for: $0, collection: collection) }, + rows: rows, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(started) + ) + } + + private func renderJSON(_ response: WeaviateHTTPResponse, started: Date) -> PluginQueryResult { + let pretty: String + if let json = response.json, JSONSerialization.isValidJSONObject(json), + let text = try? WeaviateJSON.text(json, pretty: true) { + pretty = text + } else { + pretty = response.text + } + return PluginQueryResult( + columns: ["response"], + columnTypeNames: ["json"], + rows: [[.text(pretty)]], + rowsAffected: 0, + executionTime: Date().timeIntervalSince(started) + ) + } +} diff --git a/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver+Metadata.swift b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver+Metadata.swift new file mode 100644 index 0000000000..eb3f150c54 --- /dev/null +++ b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver+Metadata.swift @@ -0,0 +1,215 @@ +import Foundation +import TableProPluginKit +import TableProWeaviateCore + +extension WeaviatePluginDriver { + func fetchDatabases() async throws -> [String] { + [WeaviatePlugin.defaultGroupName] + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + let collections = try await requireClient().schema() + remember(collections) + return collections.map { PluginTableInfo(name: $0.name, type: "TABLE") } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let collection = try await cachedCollection(table) + return WeaviateSchema.columns(for: collection).map { column in + PluginColumnInfo( + name: column.name, + dataType: column.type, + isNullable: !column.isPrimaryKey, + isPrimaryKey: column.isPrimaryKey + ) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + [ + PluginIndexInfo( + name: WeaviateSchema.uuidColumn, + columns: [WeaviateSchema.uuidColumn], + isUnique: true, + isPrimary: true, + type: "PRIMARY KEY" + ) + ] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let collections = try await requireClient().schema() + guard let collection = collections.first(where: { $0.name == table }) else { + return "{}" + } + var payload: [String: Any] = [ + "class": collection.name, + "properties": collection.properties.map { ["name": $0.name, "dataType": [$0.dataType]] } + ] + if let vectorizer = collection.vectorizer { + payload["vectorizer"] = vectorizer + } + return (try? WeaviateJSON.text(payload, pretty: true)) ?? "{}" + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + throw WeaviateError.configuration(String(localized: "Weaviate does not support views.")) + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table, engine: "Weaviate") + } + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildFilteredQuery( + table: table, + schema: nil, + queryFilters: [], + logicMode: "AND", + sortColumns: sortColumns, + columns: columns, + limit: limit, + offset: offset, + columnKinds: [:] + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + queryFilters filters: [PluginQueryFilter], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int, + columnKinds: [String: PluginColumnKind] + ) -> String? { + let sorts = sortColumns.compactMap { sort -> WeaviateSortSpec? in + guard sort.columnIndex >= 0, sort.columnIndex < columns.count else { return nil } + let column = columns[sort.columnIndex] + guard column != WeaviateSchema.vectorColumn else { return nil } + return WeaviateSortSpec(column: column, ascending: sort.ascending) + } + let specs = filters.map { filter in + WeaviateFilterSpec( + column: filter.column, + op: filter.op, + value: filter.value, + secondValue: filter.secondValue + ) + } + return WeaviateBrowseQuery.encode( + collection: table, + offset: offset, + limit: limit, + sorts: sorts, + filters: specs, + logicMode: logicMode, + propertyNames: columns + ) + } + + func generateStatements( + table: String, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let collection = rememberedCollection(table) + let typeNames = columns.map { column in + typeName(for: column, collection: collection ?? WeaviateCollection(name: table, properties: [])) + } + let tracked = changes.compactMap { change -> WeaviateTrackedChange? in + mappedChange( + change, + columns: columns, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + let batch = WeaviateStatementGenerator.generate( + collection: table, + columns: columns, + typeNames: typeNames, + changes: tracked + ) + for skipped in batch.skipped { + WeaviatePluginDriver.logger.warning( + "Skipped a \(skipped.kind.rawValue, privacy: .public) on \(table, privacy: .private): \(skipped.reason.rawValue, privacy: .public)" + ) + } + return batch.requests.map { (WeaviateWriteCodec.encode($0), []) } + } + + private func mappedChange( + _ change: PluginRowChange, + columns: [String], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> WeaviateTrackedChange? { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex) else { return nil } + var values: [String: String?] = [:] + if let row = insertedRowData[change.rowIndex] { + for (index, column) in columns.enumerated() where index < row.count { + guard let text = row[index].asText else { continue } + values[column] = text + } + } else { + for cell in change.cellChanges { + guard let text = cell.newValue.asText else { continue } + values[cell.columnName] = text + } + } + return WeaviateTrackedChange( + kind: .insert, + uuid: values[WeaviateSchema.uuidColumn] ?? nil, + values: values, + cellChanges: [] + ) + case .update: + let uuid = uuid(from: change, columns: columns) + let cells = change.cellChanges.map { + WeaviateCellChange(column: $0.columnName, newText: $0.newValue.asText) + } + return WeaviateTrackedChange(kind: .update, uuid: uuid, values: [:], cellChanges: cells) + case .delete: + guard deletedRowIndices.contains(change.rowIndex) else { return nil } + return WeaviateTrackedChange( + kind: .delete, + uuid: uuid(from: change, columns: columns), + values: [:], + cellChanges: [] + ) + } + } + + private func uuid(from change: PluginRowChange, columns: [String]) -> String? { + guard let original = change.originalRow, + let index = columns.firstIndex(of: WeaviateSchema.uuidColumn), + index < original.count + else { return nil } + return original[index].asText + } +} diff --git a/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift new file mode 100644 index 0000000000..7f869e31f1 --- /dev/null +++ b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift @@ -0,0 +1,122 @@ +import Foundation +import os +import TableProPluginKit +import TableProWeaviateCore + +internal final class WeaviatePluginDriver: PluginDatabaseDriver, @unchecked Sendable { + static let logger = Logger(subsystem: "com.TablePro", category: "WeaviatePluginDriver") + + private let config: DriverConnectionConfig + private let lock = NSLock() + private var client: WeaviateClient? + private var cachedCollections: [String: WeaviateCollection] = [:] + let queryTimeout = HttpQueryTimeoutBox() + + init(config: DriverConnectionConfig) { + self.config = config + } + + var serverVersion: String? { lock.withLock { client?.serverVersion } } + + var supportsTransactions: Bool { false } + + var capabilities: PluginCapabilities { [.cancelQuery] } + + var parameterStyle: ParameterStyle { .questionMark } + + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + func connect() async throws { + let settings = try WeaviateConnectionSettings.parse( + host: config.host, + port: config.port, + usesTLS: config.ssl.isEnabled, + fields: config.additionalFields + ) + let skipTLS = settings.skipTLSVerify + || (config.ssl.isEnabled && !config.ssl.verifiesCertificate) + let timeout = queryTimeout + let transport = URLSessionWeaviateTransport( + resourceTimeout: HttpQueryTimeout.sessionResourceTimeout, + skipTLSVerify: skipTLS + ) + let client = WeaviateClient( + settings: settings, + transport: transport, + timeout: { timeout.requestTimeoutInterval } + ) + try await client.connect() + lock.withLock { + self.client = client + cachedCollections.removeAll() + } + } + + func disconnect() { + lock.withLock { + client?.cancelAll() + client = nil + cachedCollections.removeAll() + } + } + + func ping() async throws { + try await requireClient().ping() + } + + func cancelQuery() throws { + lock.withLock { client?.cancelAll() } + } + + func applyQueryTimeout(_ seconds: Int) async throws { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + + func requireClient() throws -> WeaviateClient { + guard let client = lock.withLock({ client }) else { + throw WeaviateError.notConnected + } + return client + } + + func remember(_ collections: [WeaviateCollection]) { + lock.withLock { + for collection in collections { + cachedCollections[collection.name] = collection + } + } + } + + func rememberedCollection(_ name: String) -> WeaviateCollection? { + lock.withLock { cachedCollections[name] } + } + + func cachedCollection(_ name: String) async throws -> WeaviateCollection { + if let cached = rememberedCollection(name) { + return cached + } + let collections = try await requireClient().schema() + remember(collections) + return rememberedCollection(name) ?? WeaviateCollection(name: name, properties: []) + } + + func propertySchema(of collection: WeaviateCollection) -> [String: WeaviateProperty] { + Dictionary( + collection.properties.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + } + + func typeName(for column: String, collection: WeaviateCollection) -> String { + switch column { + case WeaviateSchema.uuidColumn: + return "uuid" + case WeaviateSchema.vectorColumn: + return "vector" + default: + return collection.properties.first { $0.name == column }?.dataType ?? "text" + } + } +} diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index da5eb3df35..6a27e6a72d 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds xlsx diff --git a/Plugins/XLSXImportPlugin/Info.plist b/Plugins/XLSXImportPlugin/Info.plist index 0f1314ddaf..8d2d3def8f 100644 --- a/Plugins/XLSXImportPlugin/Info.plist +++ b/Plugins/XLSXImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesImportFormatIds xlsx diff --git a/Plugins/XMLExportPlugin/Info.plist b/Plugins/XMLExportPlugin/Info.plist index c7a610a36f..a1a7ea95b1 100644 --- a/Plugins/XMLExportPlugin/Info.plist +++ b/Plugins/XMLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 25 + 29 TableProProvidesExportFormatIds xml diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 88ec99047b..d1128c15f8 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -66,7 +66,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// Only a screenshot run overrides the mode, so only a screenshot run resolves it twice. if let screenshotMode = ScreenshotEnvironment.appearanceMode { let appearanceSettings = AppSettingsManager.shared.appearance - ThemeEngine.shared.updateAppearanceAndTheme( + ThemeEngine.shared.apply( mode: screenshotMode, lightThemeId: appearanceSettings.preferredLightThemeId, darkThemeId: appearanceSettings.preferredDarkThemeId diff --git a/TablePro/Assets.xcassets/oceanbase-icon.imageset/Contents.json b/TablePro/Assets.xcassets/oceanbase-icon.imageset/Contents.json new file mode 100644 index 0000000000..eb0b7fe6e2 --- /dev/null +++ b/TablePro/Assets.xcassets/oceanbase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "oceanbase.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg b/TablePro/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg new file mode 100644 index 0000000000..a423b01f5c --- /dev/null +++ b/TablePro/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg @@ -0,0 +1 @@ + diff --git a/TablePro/Assets.xcassets/weaviate-icon.imageset/Contents.json b/TablePro/Assets.xcassets/weaviate-icon.imageset/Contents.json new file mode 100644 index 0000000000..fcfa2ae760 --- /dev/null +++ b/TablePro/Assets.xcassets/weaviate-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "weaviate.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TablePro/Assets.xcassets/weaviate-icon.imageset/weaviate.svg b/TablePro/Assets.xcassets/weaviate-icon.imageset/weaviate.svg new file mode 100644 index 0000000000..fafccd810f --- /dev/null +++ b/TablePro/Assets.xcassets/weaviate-icon.imageset/weaviate.svg @@ -0,0 +1 @@ +Weaviate diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index 39e7030e3a..378d170948 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -36,16 +36,17 @@ enum SQLCompletionKind: String, CaseIterable { } /// Color for the icon + @MainActor var iconColor: NSColor { switch self { - case .keyword: return .systemBlue - case .table: return .systemTeal - case .view: return .systemPurple - case .column: return .systemOrange - case .function: return .systemPink - case .schema: return .systemGreen - case .alias: return .systemGray - case .operator: return .systemIndigo + case .keyword: return ThemeEngine.shared.palette[.syntaxKeyword] + case .table: return ThemeEngine.shared.palette[.syntaxType] + case .view: return ThemeEngine.shared.palette[.syntaxType] + case .column: return ThemeEngine.shared.palette[.syntaxNull] + case .function: return ThemeEngine.shared.palette[.syntaxFunction] + case .schema: return ThemeEngine.shared.palette[.syntaxType] + case .alias: return ThemeEngine.shared.palette[.syntaxNull] + case .operator: return ThemeEngine.shared.palette[.syntaxOperator] case .favorite: return .systemYellow } } diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index 9ca4f262ad..5f41912018 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -8,19 +8,18 @@ protocol ChangeManaging: AnyObject { var reloadVersion: Int { get } var canRedo: Bool { get } var rowChanges: [RowChange] { get } - var insertedRowIndices: Set { get } + var insertedRowIDs: Set { get } var generatedColumns: Set { get } - func isRowDeleted(_ rowIndex: Int) -> Bool + func isRowDeleted(_ rowID: RowID) -> Bool func recordCellChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]? ) - func undoRowDeletion(rowIndex: Int) - func undoRowInsertion(rowIndex: Int) + func undoRowDeletion(rowID: RowID) } /// Only the data grid tracks server-computed columns; the structure and @@ -38,15 +37,15 @@ final class AnyChangeManager { var reloadVersion: Int { wrapped.reloadVersion } var canRedo: Bool { wrapped.canRedo } var rowChanges: [RowChange] { wrapped.rowChanges } - var insertedRowIndices: Set { wrapped.insertedRowIndices } + var insertedRowIDs: Set { wrapped.insertedRowIDs } var generatedColumns: Set { wrapped.generatedColumns } - func isRowDeleted(_ rowIndex: Int) -> Bool { - wrapped.isRowDeleted(rowIndex) + func isRowDeleted(_ rowID: RowID) -> Bool { + wrapped.isRowDeleted(rowID) } func recordCellChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, oldValue: PluginCellValue, @@ -54,7 +53,7 @@ final class AnyChangeManager { originalRow: [PluginCellValue] ) { wrapped.recordCellChange( - rowIndex: rowIndex, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, @@ -63,12 +62,8 @@ final class AnyChangeManager { ) } - func undoRowDeletion(rowIndex: Int) { - wrapped.undoRowDeletion(rowIndex: rowIndex) - } - - func undoRowInsertion(rowIndex: Int) { - wrapped.undoRowInsertion(rowIndex: rowIndex) + func undoRowDeletion(rowID: RowID) { + wrapped.undoRowDeletion(rowID: rowID) } init(_ manager: any ChangeManaging) { diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 30942e2781..97e482758b 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -47,8 +47,8 @@ final class DataChangeManager: ChangeManaging { var changes: [RowChange] { pending.changes } var rowChanges: [RowChange] { pending.changes } - var insertedRowIndices: Set { pending.insertedRowIndices } - var deletedRowIndices: Set { pending.deletedRowIndices } + var insertedRowIDs: Set { pending.insertedRowIDs } + var deletedRowIDs: Set { pending.deletedRowIDs } var tableName: String = "" var schemaName: String? @@ -148,7 +148,7 @@ final class DataChangeManager: ChangeManaging { // MARK: - Change Tracking func recordCellChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, oldValue: PluginCellValue, @@ -167,7 +167,7 @@ final class DataChangeManager: ChangeManaging { return } let recorded = pending.recordCellChange( - rowIndex: rowIndex, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, @@ -180,30 +180,30 @@ final class DataChangeManager: ChangeManaging { } registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( - rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue, originalRow: originalRow )) } hasChanges = !pending.isEmpty } - func recordRowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) { - pending.recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + func recordRowDeletion(rowID: RowID, originalRow: [PluginCellValue]) { + pending.recordRowDeletion(rowID: rowID, originalRow: originalRow) registerUndo(actionName: String(localized: "Delete Row")) { target in - target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + target.applyDataUndo(.rowDeletion(rowID: rowID, originalRow: originalRow)) } hasChanges = true } - func recordBatchRowDeletion(rows: [(rowIndex: Int, originalRow: [PluginCellValue])]) { + func recordBatchRowDeletion(rows: [(rowID: RowID, originalRow: [PluginCellValue])]) { guard rows.count > 1 else { if let row = rows.first { - recordRowDeletion(rowIndex: row.rowIndex, originalRow: row.originalRow) + recordRowDeletion(rowID: row.rowID, originalRow: row.originalRow) } return } - for (rowIndex, originalRow) in rows { - pending.recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + for (rowID, originalRow) in rows { + pending.recordRowDeletion(rowID: rowID, originalRow: originalRow) } let batchData = rows registerUndo(actionName: String(localized: "Delete Rows")) { target in @@ -212,32 +212,29 @@ final class DataChangeManager: ChangeManaging { hasChanges = true } - func recordRowInsertion(rowIndex: Int, values: [PluginCellValue]) { - pending.recordRowInsertion(rowIndex: rowIndex, values: values) + func recordRowInsertion(rowID: RowID, values: [PluginCellValue]) { + pending.recordRowInsertion(rowID: rowID, values: values) registerUndo(actionName: String(localized: "Insert Row")) { target in - target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) + target.applyDataUndo(.rowInsertion(rowID: rowID)) } hasChanges = true } // MARK: - Undo Operations - func undoRowDeletion(rowIndex: Int) { - guard pending.undoRowDeletion(rowIndex: rowIndex) else { return } + func undoRowDeletion(rowID: RowID) { + guard pending.undoRowDeletion(rowID: rowID) else { return } hasChanges = !pending.isEmpty } - func undoRowInsertion(rowIndex: Int) { - guard pending.undoRowInsertion(rowIndex: rowIndex) else { return } - hasChanges = !pending.isEmpty - } - - func undoBatchRowInsertion(rowIndices: [Int]) { - let validRows = rowIndices.filter { pending.isRowInserted($0) } + func undoBatchRowInsertion(rows: [InsertedRowLocation]) { + let validRows = rows.filter { pending.isRowInserted($0.rowID) } guard !validRows.isEmpty else { return } - let rowValues = pending.undoBatchRowInsertion(rowIndices: validRows, columnCount: columns.count) + let rowValues = pending.undoBatchRowInsertion( + rowIDs: validRows.map(\.rowID), columnCount: columns.count + ) registerUndo(actionName: String(localized: "Insert Rows")) { target in - target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) + target.applyDataUndo(.batchRowInsertion(rows: validRows, rowValues: rowValues)) } hasChanges = !pending.isEmpty } @@ -246,24 +243,24 @@ final class DataChangeManager: ChangeManaging { private func applyDataUndo(_ action: UndoAction) { switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue, let originalRow): + case .cellEdit(let rowID, let columnIndex, let columnName, let previousValue, let newValue, let originalRow): applyCellEditUndo( - rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: previousValue, newValue: newValue, originalRow: originalRow, action: action ) - case .rowInsertion(let rowIndex): - applyRowInsertionUndo(rowIndex: rowIndex, action: action) + case .rowInsertion(let rowID): + applyRowInsertionUndo(rowID: rowID, action: action) - case .rowDeletion(let rowIndex, let originalRow): - applyRowDeletionUndo(rowIndex: rowIndex, originalRow: originalRow, action: action) + case .rowDeletion(let rowID, let originalRow): + applyRowDeletionUndo(rowID: rowID, originalRow: originalRow, action: action) case .batchRowDeletion(let rows): applyBatchRowDeletionUndo(rows: rows, action: action) - case .batchRowInsertion(let rowIndices, let rowValues): - applyBatchRowInsertionUndo(rowIndices: rowIndices, rowValues: rowValues, action: action) + case .batchRowInsertion(let rows, let rowValues): + applyBatchRowInsertionUndo(rows: rows, rowValues: rowValues, action: action) } hasChanges = !pending.isEmpty @@ -274,79 +271,76 @@ final class DataChangeManager: ChangeManaging { } private func applyCellEditUndo( - rowIndex: Int, columnIndex: Int, columnName: String, + rowID: RowID, columnIndex: Int, columnName: String, previousValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]?, action: UndoAction ) { registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( - rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: newValue, newValue: previousValue, originalRow: originalRow )) } - if let updateChange = pending.change(forRow: rowIndex, type: .update) { + if let updateChange = pending.change(forRow: rowID, type: .update) { if updateChange.cellChanges.contains(where: { $0.columnIndex == columnIndex }) { pending.revertUpdateCell( - rowIndex: rowIndex, columnIndex: columnIndex, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: previousValue ) } - } else if pending.change(forRow: rowIndex, type: .insert) != nil { + } else if pending.change(forRow: rowID, type: .insert) != nil { pending.updateInsertedCellDirectly( - rowIndex: rowIndex, columnIndex: columnIndex, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, newValue: previousValue ) } else { pending.reapplyCellChange( - rowIndex: rowIndex, + rowID: rowID, columnIndex: columnIndex, columnName: columnName, originalDBValue: newValue, newValue: previousValue, originalRow: originalRow ) } lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil, - delta: .cellChanged(row: rowIndex, column: columnIndex) + action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil ) } - private func applyRowInsertionUndo(rowIndex: Int, action: UndoAction) { - let savedValues = pending.savedInsertedValues(forRow: rowIndex) + private func applyRowInsertionUndo(rowID: RowID, action: UndoAction) { + let savedValues = pending.savedInsertedValues(forRow: rowID) registerUndo(actionName: String(localized: "Insert Row")) { [savedValues] target in if let savedValues { - target.pending.restoreInsertedValues(forRow: rowIndex, values: savedValues) + target.pending.restoreInsertedValues(forRow: rowID, values: savedValues) } - target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) + target.applyDataUndo(.rowInsertion(rowID: rowID)) } - if pending.isRowInserted(rowIndex) { - _ = pending.undoRowInsertion(rowIndex: rowIndex) + if pending.isRowInserted(rowID) { + _ = pending.undoRowInsertion(rowID: rowID) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, - delta: .rowsRemoved(IndexSet(integer: rowIndex)) + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil ) } else { - pending.reinsertRow(rowIndex: rowIndex, columns: columns, savedValues: savedValues) + pending.reinsertRow(rowID: rowID, columns: columns, savedValues: savedValues) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues, - delta: .rowsInserted(IndexSet(integer: rowIndex)) + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues ) } } - private func applyRowDeletionUndo(rowIndex: Int, originalRow: [PluginCellValue], action: UndoAction) { + private func applyRowDeletionUndo(rowID: RowID, originalRow: [PluginCellValue], action: UndoAction) { registerUndo(actionName: String(localized: "Delete Row")) { target in - target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + target.applyDataUndo(.rowDeletion(rowID: rowID, originalRow: originalRow)) } - if pending.isRowDeleted(rowIndex) { - _ = pending.undoRowDeletion(rowIndex: rowIndex) + if pending.isRowDeleted(rowID) { + _ = pending.undoRowDeletion(rowID: rowID) lastUndoResult = UndoResult( action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow, delta: .fullReplace ) } else { - pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + pending.reapplyRowDeletion(rowID: rowID, originalRow: originalRow) lastUndoResult = UndoResult( action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, delta: .fullReplace @@ -355,24 +349,24 @@ final class DataChangeManager: ChangeManaging { } private func applyBatchRowDeletionUndo( - rows: [(rowIndex: Int, originalRow: [PluginCellValue])], action: UndoAction + rows: [(rowID: RowID, originalRow: [PluginCellValue])], action: UndoAction ) { registerUndo(actionName: String(localized: "Delete Rows")) { target in target.applyDataUndo(.batchRowDeletion(rows: rows)) } - let isUndo = rows.contains { pending.isRowDeleted($0.rowIndex) } + let isUndo = rows.contains { pending.isRowDeleted($0.rowID) } if isUndo { - for (rowIndex, _) in rows.reversed() { - _ = pending.undoRowDeletion(rowIndex: rowIndex) + for (rowID, _) in rows.reversed() { + _ = pending.undoRowDeletion(rowID: rowID) } lastUndoResult = UndoResult( action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil, delta: .fullReplace ) } else { - for (rowIndex, originalRow) in rows { - pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + for (rowID, originalRow) in rows { + pending.reapplyRowDeletion(rowID: rowID, originalRow: originalRow) } lastUndoResult = UndoResult( action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, @@ -382,25 +376,23 @@ final class DataChangeManager: ChangeManaging { } private func applyBatchRowInsertionUndo( - rowIndices: [Int], rowValues: [[PluginCellValue]], action: UndoAction + rows: [InsertedRowLocation], rowValues: [[PluginCellValue]], action: UndoAction ) { registerUndo(actionName: String(localized: "Insert Rows")) { target in - target.applyDataUndo(.batchRowInsertion(rowIndices: rowIndices, rowValues: rowValues)) + target.applyDataUndo(.batchRowInsertion(rows: rows, rowValues: rowValues)) } - let firstInserted = rowIndices.first.map { pending.isRowInserted($0) } ?? false - let indices = IndexSet(rowIndices) + let rowIDs = rows.map(\.rowID) + let firstInserted = rowIDs.first.map { pending.isRowInserted($0) } ?? false if firstInserted { - _ = pending.undoBatchRowInsertion(rowIndices: rowIndices, columnCount: columns.count) + _ = pending.undoBatchRowInsertion(rowIDs: rowIDs, columnCount: columns.count) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, - delta: .rowsRemoved(indices) + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil ) } else { - pending.reinsertBatch(rowIndices: rowIndices, rowValues: rowValues, columns: columns) + pending.reinsertBatch(rowIDs: rowIDs, rowValues: rowValues, columns: columns) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil, - delta: .rowsInserted(indices) + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil ) } } @@ -411,22 +403,22 @@ final class DataChangeManager: ChangeManaging { try generateSQL( for: pending.changes, insertedRowData: pending.insertedRowData, - deletedRowIndices: pending.deletedRowIndices, - insertedRowIndices: pending.insertedRowIndices + deletedRowIDs: pending.deletedRowIDs, + insertedRowIDs: pending.insertedRowIDs ) } func generateSQL( for changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]] = [:], - deletedRowIndices: Set = [], - insertedRowIndices: Set = [] + insertedRowData: [RowID: [PluginCellValue]] = [:], + deletedRowIDs: Set = [], + insertedRowIDs: Set = [] ) throws -> [ParameterizedStatement] { try statementFactory().statements( for: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ) } @@ -440,8 +432,8 @@ final class DataChangeManager: ChangeManaging { let operations = RowWriteOperationBuilder.operations( from: pending.changes, insertedRowData: pending.insertedRowData, - deletedRowIndices: pending.deletedRowIndices, - insertedRowIndices: pending.insertedRowIndices, + deletedRowIDs: pending.deletedRowIDs, + insertedRowIDs: pending.insertedRowIDs, target: DataWriteTarget(database: database, schema: schema, table: tableName), columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -452,8 +444,8 @@ final class DataChangeManager: ChangeManaging { if let attributed = try factory.attributedStatements( for: pending.changes, insertedRowData: pending.insertedRowData, - deletedRowIndices: pending.deletedRowIndices, - insertedRowIndices: pending.insertedRowIndices + deletedRowIDs: pending.deletedRowIDs, + insertedRowIDs: pending.insertedRowIDs ) { let steps = attributed.map { DataWriteStep( @@ -469,8 +461,8 @@ final class DataChangeManager: ChangeManaging { let steps = try factory.statements( for: pending.changes, insertedRowData: pending.insertedRowData, - deletedRowIndices: pending.deletedRowIndices, - insertedRowIndices: pending.insertedRowIndices + deletedRowIDs: pending.deletedRowIDs, + insertedRowIDs: pending.insertedRowIDs ).map { DataWriteStep(kind: .rowWrite, statement: $0, expectedRowCount: nil, tableName: tableName) } @@ -495,12 +487,12 @@ final class DataChangeManager: ChangeManaging { // MARK: - Actions - func getOriginalValues() -> [(rowIndex: Int, columnIndex: Int, value: PluginCellValue)] { - var originals: [(rowIndex: Int, columnIndex: Int, value: PluginCellValue)] = [] + func getOriginalValues() -> [(rowID: RowID, columnIndex: Int, value: PluginCellValue)] { + var originals: [(rowID: RowID, columnIndex: Int, value: PluginCellValue)] = [] for change in pending.changes where change.type == .update { for cellChange in change.cellChanges { originals.append(( - rowIndex: change.rowIndex, + rowID: change.rowID, columnIndex: cellChange.columnIndex, value: cellChange.oldValue )) @@ -542,19 +534,19 @@ final class DataChangeManager: ChangeManaging { // MARK: - O(1) Lookups - func isRowDeleted(_ rowIndex: Int) -> Bool { - pending.isRowDeleted(rowIndex) + func isRowDeleted(_ rowID: RowID) -> Bool { + pending.isRowDeleted(rowID) } - func isRowInserted(_ rowIndex: Int) -> Bool { - pending.isRowInserted(rowIndex) + func isRowInserted(_ rowID: RowID) -> Bool { + pending.isRowInserted(rowID) } - func isCellModified(rowIndex: Int, columnIndex: Int) -> Bool { - pending.isCellModified(rowIndex: rowIndex, columnIndex: columnIndex) + func isCellModified(rowID: RowID, columnIndex: Int) -> Bool { + pending.isCellModified(rowID: rowID, columnIndex: columnIndex) } - func getModifiedColumnsForRow(_ rowIndex: Int) -> Set { - pending.modifiedColumns(forRow: rowIndex) + func getModifiedColumnsForRow(_ rowID: RowID) -> Set { + pending.modifiedColumns(forRow: rowID) } } diff --git a/TablePro/Core/ChangeTracking/DataChangeModels.swift b/TablePro/Core/ChangeTracking/DataChangeModels.swift index ad37d3b007..1ef76b0850 100644 --- a/TablePro/Core/ChangeTracking/DataChangeModels.swift +++ b/TablePro/Core/ChangeTracking/DataChangeModels.swift @@ -35,7 +35,7 @@ struct CellChange: Identifiable, Equatable { struct RowChange: Identifiable, Equatable { let id: UUID - var rowIndex: Int + let rowID: RowID let type: ChangeType var cellChanges: [CellChange] let originalRow: [PluginCellValue]? @@ -49,14 +49,14 @@ struct RowChange: Identifiable, Equatable { var sequence: Int init( - rowIndex: Int, + rowID: RowID, type: ChangeType, cellChanges: [CellChange] = [], originalRow: [PluginCellValue]? = nil, sequence: Int = 0 ) { self.id = UUID() - self.rowIndex = rowIndex + self.rowID = rowID self.type = type self.cellChanges = cellChanges self.originalRow = originalRow @@ -65,21 +65,26 @@ struct RowChange: Identifiable, Equatable { } struct RowChangeKey: Hashable { - let rowIndex: Int + let rowID: RowID let type: ChangeType } +struct InsertedRowLocation { + let rowID: RowID + let storageIndex: Int +} + enum UndoAction { case cellEdit( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, previousValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]? ) - case rowInsertion(rowIndex: Int) - case rowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) - case batchRowDeletion(rows: [(rowIndex: Int, originalRow: [PluginCellValue])]) - case batchRowInsertion(rowIndices: [Int], rowValues: [[PluginCellValue]]) + case rowInsertion(rowID: RowID) + case rowDeletion(rowID: RowID, originalRow: [PluginCellValue]) + case batchRowDeletion(rows: [(rowID: RowID, originalRow: [PluginCellValue])]) + case batchRowInsertion(rows: [InsertedRowLocation], rowValues: [[PluginCellValue]]) } diff --git a/TablePro/Core/ChangeTracking/PendingChanges.swift b/TablePro/Core/ChangeTracking/PendingChanges.swift index 6bc949235c..28dd97c47f 100644 --- a/TablePro/Core/ChangeTracking/PendingChanges.swift +++ b/TablePro/Core/ChangeTracking/PendingChanges.swift @@ -4,7 +4,7 @@ // // Value type holding all uncommitted edits to a result set. // Owns the consistency invariants between `changes`, `changeIndex`, -// `deletedRowIndices`, `insertedRowIndices`, `modifiedCells`, and +// `deletedRowIDs`, `insertedRowIDs`, `modifiedCells`, and // `insertedRowData`. Callers mutate through methods that maintain // the cross-collection state. // @@ -14,15 +14,15 @@ import TableProPluginKit struct PendingChanges: Equatable { private(set) var changes: [RowChange] = [] - private(set) var deletedRowIndices: Set = [] + private(set) var deletedRowIDs: Set = [] /// Stamped onto every change so statement generation can recover the order the user worked in. /// `changes` cannot carry that order itself: a cancelled change is removed by swapping the last /// element into its slot. private var nextSequence = 0 - private(set) var insertedRowIndices: Set = [] - private(set) var modifiedCells: [Int: Set] = [:] - private(set) var insertedRowData: [Int: [PluginCellValue]] = [:] + private(set) var insertedRowIDs: Set = [] + private(set) var modifiedCells: [RowID: Set] = [:] + private(set) var insertedRowData: [RowID: [PluginCellValue]] = [:] private var changeIndex: [RowChangeKey: Int] = [:] @@ -31,24 +31,24 @@ struct PendingChanges: Equatable { // MARK: - Read - func isRowDeleted(_ rowIndex: Int) -> Bool { - deletedRowIndices.contains(rowIndex) + func isRowDeleted(_ rowID: RowID) -> Bool { + deletedRowIDs.contains(rowID) } - func isRowInserted(_ rowIndex: Int) -> Bool { - insertedRowIndices.contains(rowIndex) + func isRowInserted(_ rowID: RowID) -> Bool { + insertedRowIDs.contains(rowID) } - func isCellModified(rowIndex: Int, columnIndex: Int) -> Bool { - modifiedCells[rowIndex]?.contains(columnIndex) == true + func isCellModified(rowID: RowID, columnIndex: Int) -> Bool { + modifiedCells[rowID]?.contains(columnIndex) == true } - func modifiedColumns(forRow rowIndex: Int) -> Set { - modifiedCells[rowIndex] ?? [] + func modifiedColumns(forRow rowID: RowID) -> Set { + modifiedCells[rowID] ?? [] } - func change(forRow rowIndex: Int, type: ChangeType) -> RowChange? { - guard let idx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: type)] else { return nil } + func change(forRow rowID: RowID, type: ChangeType) -> RowChange? { + guard let idx = changeIndex[RowChangeKey(rowID: rowID, type: type)] else { return nil } return changes[idx] } @@ -58,7 +58,7 @@ struct PendingChanges: Equatable { /// Returns the result so the caller can decide whether to register undo. @discardableResult mutating func recordCellChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, oldValue: PluginCellValue, @@ -67,7 +67,7 @@ struct PendingChanges: Equatable { ) -> Bool { if oldValue == newValue { return rollbackCellIfMatchesOriginal( - rowIndex: rowIndex, columnIndex: columnIndex, restoredValue: newValue + rowID: rowID, columnIndex: columnIndex, restoredValue: newValue ) } @@ -78,102 +78,91 @@ struct PendingChanges: Equatable { newValue: newValue ) - if let insertIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] { + if let insertIdx = changeIndex[RowChangeKey(rowID: rowID, type: .insert)] { updateInsertedCell(at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue) return true } - let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + let updateKey = RowChangeKey(rowID: rowID, type: .update) if let updateIdx = changeIndex[updateKey] { mergeUpdateCell(at: updateIdx, cellChange: cellChange) } else { let row = RowChange( - rowIndex: rowIndex, type: .update, + rowID: rowID, type: .update, cellChanges: [cellChange], originalRow: originalRow ) changes.append(row) changeIndex[updateKey] = changes.count - 1 - modifiedCells[rowIndex, default: []].insert(columnIndex) + modifiedCells[rowID, default: []].insert(columnIndex) } return true } - mutating func recordRowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) { - guard !deletedRowIndices.contains(rowIndex) else { return } - removeChange(rowIndex: rowIndex, type: .update) - modifiedCells.removeValue(forKey: rowIndex) - appendChange(RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow)) - deletedRowIndices.insert(rowIndex) + mutating func recordRowDeletion(rowID: RowID, originalRow: [PluginCellValue]) { + guard !deletedRowIDs.contains(rowID) else { return } + removeChange(rowID: rowID, type: .update) + modifiedCells.removeValue(forKey: rowID) + appendChange(RowChange(rowID: rowID, type: .delete, originalRow: originalRow)) + deletedRowIDs.insert(rowID) } - mutating func recordRowInsertion(rowIndex: Int, values: [PluginCellValue]) { - guard !insertedRowIndices.contains(rowIndex) else { - insertedRowData[rowIndex] = values + mutating func recordRowInsertion(rowID: RowID, values: [PluginCellValue]) { + guard !insertedRowIDs.contains(rowID) else { + insertedRowData[rowID] = values return } - insertedRowData[rowIndex] = values - appendChange(RowChange(rowIndex: rowIndex, type: .insert, cellChanges: [])) - insertedRowIndices.insert(rowIndex) + insertedRowData[rowID] = values + appendChange(RowChange(rowID: rowID, type: .insert, cellChanges: [])) + insertedRowIDs.insert(rowID) } // MARK: - Mutate (cancelling pending edits) - mutating func undoRowDeletion(rowIndex: Int) -> Bool { - guard deletedRowIndices.contains(rowIndex) else { return false } - removeChange(rowIndex: rowIndex, type: .delete) - deletedRowIndices.remove(rowIndex) + mutating func undoRowDeletion(rowID: RowID) -> Bool { + guard deletedRowIDs.contains(rowID) else { return false } + removeChange(rowID: rowID, type: .delete) + deletedRowIDs.remove(rowID) return true } - mutating func undoRowInsertion(rowIndex: Int) -> Bool { - guard insertedRowIndices.contains(rowIndex) else { return false } - - removeChange(rowIndex: rowIndex, type: .insert) - insertedRowIndices.remove(rowIndex) - insertedRowData.removeValue(forKey: rowIndex) - - shiftRowIndicesDown(at: rowIndex) + mutating func undoRowInsertion(rowID: RowID) -> Bool { + guard insertedRowIDs.contains(rowID) else { return false } + removeChange(rowID: rowID, type: .insert) + insertedRowIDs.remove(rowID) + insertedRowData.removeValue(forKey: rowID) return true } /// Undo a batch of inserted rows. Returns the saved values for each row in the same order. - mutating func undoBatchRowInsertion(rowIndices: [Int], columnCount: Int) -> [[PluginCellValue]] { - let validRows = rowIndices.filter { insertedRowIndices.contains($0) } + mutating func undoBatchRowInsertion(rowIDs: [RowID], columnCount: Int) -> [[PluginCellValue]] { + let validRows = rowIDs.filter { insertedRowIDs.contains($0) } /// `insertedRowData` holds the whole row. `cellChanges` holds only the columns the user /// typed, so rebuilding from it drops the untouched ones and slides the rest left: a name /// typed into the third column comes back in the first. - let rowValues = validRows.map { rowIndex in - insertedRowData[rowIndex] ?? Array(repeating: PluginCellValue.null, count: columnCount) + let rowValues = validRows.map { rowID in + insertedRowData[rowID] ?? Array(repeating: PluginCellValue.null, count: columnCount) } - for rowIndex in validRows { - removeChange(rowIndex: rowIndex, type: .insert) - insertedRowIndices.remove(rowIndex) - insertedRowData.removeValue(forKey: rowIndex) + for rowID in validRows { + _ = undoRowInsertion(rowID: rowID) } - - shiftRowIndicesDown(atSortedRows: validRows.sorted()) return rowValues } // MARK: - Replay (driven by NSUndoManager invocation) /// Re-apply a deletion during undo replay (skips undo registration). - mutating func reapplyRowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) { - guard !deletedRowIndices.contains(rowIndex) else { return } - removeChange(rowIndex: rowIndex, type: .update) - modifiedCells.removeValue(forKey: rowIndex) - appendChange(RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow)) - deletedRowIndices.insert(rowIndex) + mutating func reapplyRowDeletion(rowID: RowID, originalRow: [PluginCellValue]) { + recordRowDeletion(rowID: rowID, originalRow: originalRow) } /// Re-apply a cell edit during undo replay (skips undo registration). /// `originalDBValue` is the cell's value in the unmodified database row. /// It must be preserved so that a later collapse compares correctly. mutating func reapplyCellChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, originalDBValue: PluginCellValue, @@ -187,55 +176,52 @@ struct PendingChanges: Equatable { newValue: newValue ) - if let insertIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] { + if let insertIdx = changeIndex[RowChangeKey(rowID: rowID, type: .insert)] { updateInsertedCell(at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue) return } - let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + let updateKey = RowChangeKey(rowID: rowID, type: .update) if let updateIdx = changeIndex[updateKey] { mergeUpdateCell(at: updateIdx, cellChange: cellChange) } else { let row = RowChange( - rowIndex: rowIndex, type: .update, + rowID: rowID, type: .update, cellChanges: [cellChange], originalRow: originalRow ) changes.append(row) changeIndex[updateKey] = changes.count - 1 - modifiedCells[rowIndex, default: []].insert(columnIndex) + modifiedCells[rowID, default: []].insert(columnIndex) } } - /// Replace an inserted row's cell value during undo replay (no shift, no undo). + /// Replace an inserted row's cell value during undo replay (no undo). mutating func updateInsertedCellDirectly( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, newValue: PluginCellValue ) { - guard let insertIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] else { return } + guard let insertIdx = changeIndex[RowChangeKey(rowID: rowID, type: .insert)] else { return } updateInsertedCell(at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue) } /// Restore a cell's value during undo replay when an existing change matches. mutating func revertUpdateCell( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, previousValue: PluginCellValue ) { - guard let updateIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .update)], + guard let updateIdx = changeIndex[RowChangeKey(rowID: rowID, type: .update)], let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) else { return } let originalOldValue = changes[updateIdx].cellChanges[cellIdx].oldValue if previousValue == originalOldValue { changes[updateIdx].cellChanges.remove(at: cellIdx) - modifiedCells[rowIndex]?.remove(columnIndex) - if modifiedCells[rowIndex]?.isEmpty == true { - modifiedCells.removeValue(forKey: rowIndex) - } + removeModifiedCell(rowID: rowID, columnIndex: columnIndex) if changes[updateIdx].cellChanges.isEmpty { removeChangeAt(updateIdx) } @@ -250,31 +236,25 @@ struct PendingChanges: Equatable { } /// Insert a synthetic .insert RowChange for undo replay (e.g., after redoing a deletion's undo). - mutating func reinsertRow(rowIndex: Int, columns: [String], savedValues: [PluginCellValue]?) { - shiftRowIndicesUp(from: rowIndex) - insertedRowIndices.insert(rowIndex) + mutating func reinsertRow(rowID: RowID, columns: [String], savedValues: [PluginCellValue]?) { + insertedRowIDs.insert(rowID) let cellChanges = columns.enumerated().map { index, columnName in CellChange( columnIndex: index, columnName: columnName, oldValue: nil, newValue: savedValues?[safe: index] ?? nil ) } - appendChange(RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges)) + appendChange(RowChange(rowID: rowID, type: .insert, cellChanges: cellChanges)) if let savedValues { - insertedRowData[rowIndex] = savedValues + insertedRowData[rowID] = savedValues } } /// Insert a batch of rows (for undo replay of a batch deletion's undo). mutating func reinsertBatch( - rowIndices: [Int], rowValues: [[PluginCellValue]], columns: [String] + rowIDs: [RowID], rowValues: [[PluginCellValue]], columns: [String] ) { - for rowIndex in rowIndices.sorted() { - shiftRowIndicesUp(from: rowIndex) - } - for (index, rowIndex) in rowIndices.enumerated().reversed() { - guard index < rowValues.count else { continue } - let values = rowValues[index] + for (rowID, values) in zip(rowIDs, rowValues) { let cellChanges = values.enumerated().map { colIndex, value in CellChange( columnIndex: colIndex, @@ -282,21 +262,20 @@ struct PendingChanges: Equatable { oldValue: nil, newValue: value ) } - appendChange(RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges)) - insertedRowIndices.insert(rowIndex) - insertedRowData[rowIndex] = values + appendChange(RowChange(rowID: rowID, type: .insert, cellChanges: cellChanges)) + insertedRowIDs.insert(rowID) + insertedRowData[rowID] = values } - rebuildChangeIndex() } /// Save inserted-row values for a redo replay closure that may need them. - func savedInsertedValues(forRow rowIndex: Int) -> [PluginCellValue]? { - insertedRowData[rowIndex] + func savedInsertedValues(forRow rowID: RowID) -> [PluginCellValue]? { + insertedRowData[rowID] } /// Restore inserted-row values when undo restores a row. - mutating func restoreInsertedValues(forRow rowIndex: Int, values: [PluginCellValue]) { - insertedRowData[rowIndex] = values + mutating func restoreInsertedValues(forRow rowID: RowID, values: [PluginCellValue]) { + insertedRowData[rowID] = values } // MARK: - Reset / persistence @@ -305,16 +284,16 @@ struct PendingChanges: Equatable { nextSequence = 0 changes.removeAll() changeIndex.removeAll() - deletedRowIndices.removeAll() - insertedRowIndices.removeAll() + deletedRowIDs.removeAll() + insertedRowIDs.removeAll() modifiedCells.removeAll() insertedRowData.removeAll() } mutating func restore(from snapshot: TabChangeSnapshot) { changes = snapshot.changes - deletedRowIndices = snapshot.deletedRowIndices - insertedRowIndices = snapshot.insertedRowIndices + deletedRowIDs = snapshot.deletedRowIDs + insertedRowIDs = snapshot.insertedRowIDs modifiedCells = snapshot.modifiedCells insertedRowData = snapshot.insertedRowData nextSequence = (changes.map(\.sequence).max() ?? -1) + 1 @@ -324,8 +303,8 @@ struct PendingChanges: Equatable { func snapshot(primaryKeyColumns: [String], columns: [String]) -> TabChangeSnapshot { var snap = TabChangeSnapshot() snap.changes = changes - snap.deletedRowIndices = deletedRowIndices - snap.insertedRowIndices = insertedRowIndices + snap.deletedRowIDs = deletedRowIDs + snap.insertedRowIDs = insertedRowIDs snap.modifiedCells = modifiedCells snap.insertedRowData = insertedRowData snap.primaryKeyColumns = primaryKeyColumns @@ -340,12 +319,12 @@ struct PendingChanges: Equatable { stamped.sequence = nextSequence nextSequence += 1 changes.append(stamped) - changeIndex[RowChangeKey(rowIndex: stamped.rowIndex, type: stamped.type)] = changes.count - 1 + changeIndex[RowChangeKey(rowID: stamped.rowID, type: stamped.type)] = changes.count - 1 } @discardableResult - private mutating func removeChange(rowIndex: Int, type: ChangeType) -> Bool { - let key = RowChangeKey(rowIndex: rowIndex, type: type) + private mutating func removeChange(rowID: RowID, type: ChangeType) -> Bool { + let key = RowChangeKey(rowID: rowID, type: type) guard let arrayIndex = changeIndex[key] else { return false } removeChangeAt(arrayIndex) return true @@ -353,13 +332,13 @@ struct PendingChanges: Equatable { private mutating func removeChangeAt(_ arrayIndex: Int) { let removed = changes[arrayIndex] - changeIndex.removeValue(forKey: RowChangeKey(rowIndex: removed.rowIndex, type: removed.type)) + changeIndex.removeValue(forKey: RowChangeKey(rowID: removed.rowID, type: removed.type)) let lastIndex = changes.count - 1 if arrayIndex != lastIndex { let moved = changes[lastIndex] changes.swapAt(arrayIndex, lastIndex) - changeIndex[RowChangeKey(rowIndex: moved.rowIndex, type: moved.type)] = arrayIndex + changeIndex[RowChangeKey(rowID: moved.rowID, type: moved.type)] = arrayIndex } changes.removeLast() } @@ -367,17 +346,24 @@ struct PendingChanges: Equatable { private mutating func rebuildChangeIndex() { changeIndex.removeAll(keepingCapacity: true) for (index, change) in changes.enumerated() { - changeIndex[RowChangeKey(rowIndex: change.rowIndex, type: change.type)] = index + changeIndex[RowChangeKey(rowID: change.rowID, type: change.type)] = index + } + } + + private mutating func removeModifiedCell(rowID: RowID, columnIndex: Int) { + modifiedCells[rowID]?.remove(columnIndex) + if modifiedCells[rowID]?.isEmpty == true { + modifiedCells.removeValue(forKey: rowID) } } private mutating func updateInsertedCell( at insertIdx: Int, columnIndex: Int, columnName: String, newValue: PluginCellValue ) { - let rowIndex = changes[insertIdx].rowIndex - if var stored = insertedRowData[rowIndex], columnIndex < stored.count { + let rowID = changes[insertIdx].rowID + if var stored = insertedRowData[rowID], columnIndex < stored.count { stored[columnIndex] = newValue - insertedRowData[rowIndex] = stored + insertedRowData[rowID] = stored } let replacement = CellChange( @@ -392,113 +378,46 @@ struct PendingChanges: Equatable { } private mutating func mergeUpdateCell(at updateIdx: Int, cellChange: CellChange) { - let rowIndex = changes[updateIdx].rowIndex - if let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { + let rowID = changes[updateIdx].rowID + guard let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { $0.columnIndex == cellChange.columnIndex - }) { - let originalOldValue = changes[updateIdx].cellChanges[cellIdx].oldValue - let merged = CellChange( - columnIndex: cellChange.columnIndex, - columnName: cellChange.columnName, - oldValue: originalOldValue, - newValue: cellChange.newValue - ) - changes[updateIdx].cellChanges[cellIdx] = merged - - if originalOldValue == cellChange.newValue { - changes[updateIdx].cellChanges.remove(at: cellIdx) - modifiedCells[rowIndex]?.remove(cellChange.columnIndex) - if modifiedCells[rowIndex]?.isEmpty == true { - modifiedCells.removeValue(forKey: rowIndex) - } - if changes[updateIdx].cellChanges.isEmpty { - removeChangeAt(updateIdx) - } - } - } else { + }) else { changes[updateIdx].cellChanges.append(cellChange) - modifiedCells[rowIndex, default: []].insert(cellChange.columnIndex) + modifiedCells[rowID, default: []].insert(cellChange.columnIndex) + return + } + + let originalOldValue = changes[updateIdx].cellChanges[cellIdx].oldValue + changes[updateIdx].cellChanges[cellIdx] = CellChange( + columnIndex: cellChange.columnIndex, + columnName: cellChange.columnName, + oldValue: originalOldValue, + newValue: cellChange.newValue + ) + + guard originalOldValue == cellChange.newValue else { return } + changes[updateIdx].cellChanges.remove(at: cellIdx) + removeModifiedCell(rowID: rowID, columnIndex: cellChange.columnIndex) + if changes[updateIdx].cellChanges.isEmpty { + removeChangeAt(updateIdx) } } @discardableResult private mutating func rollbackCellIfMatchesOriginal( - rowIndex: Int, columnIndex: Int, restoredValue: PluginCellValue + rowID: RowID, columnIndex: Int, restoredValue: PluginCellValue ) -> Bool { - let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + let updateKey = RowChangeKey(rowID: rowID, type: .update) guard let updateIdx = changeIndex[updateKey], let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }), changes[updateIdx].cellChanges[cellIdx].oldValue == restoredValue else { return false } changes[updateIdx].cellChanges.remove(at: cellIdx) - modifiedCells[rowIndex]?.remove(columnIndex) - if modifiedCells[rowIndex]?.isEmpty == true { - modifiedCells.removeValue(forKey: rowIndex) - } + removeModifiedCell(rowID: rowID, columnIndex: columnIndex) if changes[updateIdx].cellChanges.isEmpty { removeChangeAt(updateIdx) } return true } - - /// Renumbers every collection this type keys by row index, in one place. - /// - /// The state is six things that have to agree: `changes[].rowIndex`, `changeIndex`, - /// `insertedRowIndices`, `deletedRowIndices`, `insertedRowData` and `modifiedCells`. There used - /// to be three renumbering paths handling three different subsets of them, and the gaps were - /// invisible: undoing one row of a pasted batch left the surviving rows' values filed under - /// their old indices, so Save wrote one row with another row's values and dropped the rest - /// without reporting anything. A single primitive is what makes that class of omission - /// impossible rather than merely absent. - /// - /// A new row always lands at the end of the grid, so nothing pending is ever below an inserted - /// one and the delete and modified-cell arms do not fire today. They are here because the - /// alternative is three renumbering paths covering three different subsets again, which is - /// what this replaced. - private mutating func reindex(_ transform: (Int) -> Int) { - for i in 0 ..< changes.count { - changes[i].rowIndex = transform(changes[i].rowIndex) - } - insertedRowIndices = Set(insertedRowIndices.map(transform)) - deletedRowIndices = Set(deletedRowIndices.map(transform)) - insertedRowData = Dictionary( - uniqueKeysWithValues: insertedRowData.map { (transform($0.key), $0.value) } - ) - modifiedCells = Dictionary( - uniqueKeysWithValues: modifiedCells.map { (transform($0.key), $0.value) } - ) - rebuildChangeIndex() - } - - private mutating func shiftRowIndicesUp(from insertionPoint: Int) { - reindex { $0 >= insertionPoint ? $0 + 1 : $0 } - } - - private mutating func shiftRowIndicesDown(at removedRow: Int) { - modifiedCells.removeValue(forKey: removedRow) - reindex { $0 > removedRow ? $0 - 1 : $0 } - } - - /// The same renumbering for a whole batch removed at once. - private mutating func shiftRowIndicesDown(atSortedRows removedRows: [Int]) { - for removedRow in removedRows { - modifiedCells.removeValue(forKey: removedRow) - } - reindex { $0 - Self.countLessThan($0, in: removedRows) } - } - - /// Binary search: count of elements strictly less than `target` in a sorted array. - private static func countLessThan(_ target: Int, in sorted: [Int]) -> Int { - var lo = 0, hi = sorted.count - while lo < hi { - let mid = (lo + hi) / 2 - if sorted[mid] < target { - lo = mid + 1 - } else { - hi = mid - } - } - return lo - } } diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 658d7bb227..bb55dedc58 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -88,20 +88,20 @@ struct SQLStatementGenerator { /// - Parameters: /// - changes: Array of row changes to process /// - insertedRowData: Lazy storage for inserted row values - /// - deletedRowIndices: Set of deleted row indices for validation - /// - insertedRowIndices: Set of inserted row indices for validation + /// - deletedRowIDs: Rows still marked as deleted + /// - insertedRowIDs: Rows still marked as inserted /// - Returns: Array of parameterized SQL statements func generateStatements( from changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]], - deletedRowIndices: Set, - insertedRowIndices: Set + insertedRowData: [RowID: [PluginCellValue]], + deletedRowIDs: Set, + insertedRowIDs: Set ) -> [ParameterizedStatement] { generateAttributedStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ).map(\.statement) } @@ -117,9 +117,9 @@ struct SQLStatementGenerator { /// separated from another delete by an insert stays on its own side of it. func generateAttributedStatements( from changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]], - deletedRowIndices: Set, - insertedRowIndices: Set + insertedRowData: [RowID: [PluginCellValue]], + deletedRowIDs: Set, + insertedRowIDs: Set ) -> [AttributedStatement] { var statements: [AttributedStatement] = [] var deleteRun: [RowChange] = [] @@ -139,14 +139,14 @@ struct SQLStatementGenerator { } case .insert: // SAFETY: Verify the row is still marked as inserted - guard insertedRowIndices.contains(change.rowIndex) else { continue } + guard insertedRowIDs.contains(change.rowID) else { continue } flushDeleteRun() if let stmt = generateInsertSQL(for: change, insertedRowData: insertedRowData) { statements.append(AttributedStatement(statement: stmt, kind: .insert, rowCount: 1)) } case .delete: // SAFETY: Verify the row is still marked as deleted - guard deletedRowIndices.contains(change.rowIndex) else { continue } + guard deletedRowIDs.contains(change.rowID) else { continue } deleteRun.append(change) } } @@ -166,16 +166,16 @@ struct SQLStatementGenerator { // MARK: - INSERT Generation - private func generateInsertSQL(for change: RowChange, insertedRowData: [Int: [PluginCellValue]]) + private func generateInsertSQL(for change: RowChange, insertedRowData: [RowID: [PluginCellValue]]) -> ParameterizedStatement? { - if let values = insertedRowData[change.rowIndex] { - return generateInsertSQLFromStoredData(rowIndex: change.rowIndex, values: values) + if let values = insertedRowData[change.rowID] { + return generateInsertSQLFromStoredData(values: values) } return generateInsertSQLFromCellChanges(for: change) } - private func generateInsertSQLFromStoredData(rowIndex: Int, values: [PluginCellValue]) + private func generateInsertSQLFromStoredData(values: [PluginCellValue]) -> ParameterizedStatement? { var nonDefaultColumns: [String] = [] diff --git a/TablePro/Core/Compare/CompareSQLLiteral.swift b/TablePro/Core/Compare/CompareSQLLiteral.swift index 5d5118e68c..4c49f5b969 100644 --- a/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -22,12 +22,20 @@ internal enum CompareSQLLiteral { driver: any PluginDatabaseDriver ) -> String { guard case .bytes(let data) = value else { - return driver.sqlLiteral(for: value) + return prefixed(driver.sqlLiteral(for: value), databaseType: databaseType) } return binaryLiteral(for: data, databaseType: databaseType) ?? driver.sqlLiteral(for: value) } + /// The prefix belongs to a quoted literal and to nothing else. `sqlLiteral(for:)` answers + /// `NULL` for a null and passes a number through unquoted, and `N` in front of either is a + /// syntax error, so the opening quote is what decides. + internal static func prefixed(_ literal: String, databaseType: DatabaseType) -> String { + guard literal.hasPrefix("'") else { return literal } + return SQLStringLiteralPrefix.forDatabaseType(databaseType) + literal + } + internal static func binaryLiteral(for data: Data, databaseType: DatabaseType) -> String? { let hex = data.map { String(format: "%02X", $0) }.joined() switch binaryStyle(for: databaseType) { @@ -56,8 +64,8 @@ internal enum CompareSQLLiteral { /// name falls back to the driver's own spelling rather than guessing at one. internal static func binaryStyle(for databaseType: DatabaseType) -> BinaryStyle { switch databaseType { - case .mysql, .mariadb, .tidb, .databend, .sqlite, .clickhouse, .duckdb, .libsql, .turso, - .cloudflareD1: + case .mysql, .mariadb, .tidb, .databend, .oceanbase, .sqlite, .clickhouse, .duckdb, .libsql, + .turso, .cloudflareD1: return .bitString case .postgresql, .cockroachdb, .redshift, .pglite: return .postgresBytea diff --git a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift index 25e83de1ee..7dc76c44cd 100644 --- a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift +++ b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift @@ -96,6 +96,13 @@ internal struct SchemaSyncScriptBuilder { charset: snapshot.charset, collation: snapshot.collation ) + if let reason = SchemaOperationRefusal.reason(for: definition, driver: targetDriver) { + throw CompareSyncError.unsupportedOperation(String( + format: String(localized: "Cannot create table %@: %@"), + snapshot.name, + reason + )) + } guard let sql = targetDriver.generateCreateTableSQL(definition: definition) else { throw CompareSyncError.unsupportedOperation(String( format: String(localized: "The target does not support creating table %@."), diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 6da10567c3..48ca9ce426 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -633,7 +633,8 @@ final class FilterCoordinator { let generator = FilterSQLGenerator( dialect: dialect, columns: queryColumns?.columns ?? [], - columnTypes: queryColumns?.columnTypes ?? [] + columnTypes: queryColumns?.columnTypes ?? [], + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType) ) let filtersToPreview = filtersForPreview(in: state) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index f2748b7b9f..8d8ecb4144 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -484,6 +484,7 @@ extension QueryExecutionCoordinator { primaryKeyColumns: primaryKeyColumns, isEditable: isEditable, isView: context.isView, + objectType: context.objectType, keysResolved: true ) } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index 97f8f5faf1..be0aa3e3ca 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -80,6 +80,7 @@ extension QueryExecutionCoordinator { primaryKeyColumns: [], isEditable: resolved.isEditable && producesRows, isView: tab.tableContext.isView, + objectType: tab.tableContext.objectType, keysResolved: false ) } diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift index 706c9e02f9..164cc3960e 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift @@ -75,15 +75,16 @@ extension RowEditingCoordinator { /// them, and the next edit captures an unsaved value as its baseline. A discard that re-queries /// replaces the buffer wholesale and needs none of this; one that does not has to undo it here. func restoreRowBufferToOriginals() { - let originalValues = parent.changeManager.getOriginalValues() var deltas: [Delta] = [] - if let (tab, _) = parent.tabManager.selectedTabAndIndex { + if let (tab, _) = parent.tabManager.selectedTabAndIndex, + let tableRows = parent.tabSessionRegistry.existingTableRows(for: tab.id) { let tabId = tab.id - let insertedIDs = collectInsertedRowIDs( - tabId: tabId, - indices: parent.changeManager.insertedRowIndices - ) - let edits = originalValues.map { (row: $0.0, column: $0.1, value: $0.2) } + let insertedIDs = parent.changeManager.insertedRowIDs + let edits = parent.changeManager.getOriginalValues().compactMap { original in + tableRows.index(of: original.rowID).map { + (row: $0, column: original.columnIndex, value: original.value) + } + } if !edits.isEmpty { let editDelta = parent.mutateActiveTableRows(for: tabId) { rows in rows.editMany(edits) @@ -106,17 +107,4 @@ extension RowEditingCoordinator { parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) } } - - private func collectInsertedRowIDs(tabId: UUID, indices: Set) -> Set { - guard !indices.isEmpty else { return [] } - guard let tableRows = parent.tabSessionRegistry.existingTableRows(for: tabId) else { return [] } - var ids = Set() - for index in indices where index >= 0 && index < tableRows.rows.count { - let id = tableRows.rows[index].id - if id.isInserted { - ids.insert(id) - } - } - return ids - } } diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift index 58897638e6..a916a71a90 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift @@ -78,7 +78,7 @@ extension RowEditingCoordinator { } let connId = parent.connection.id let kind: OperationKind = hasPendingTableOps ? .destructiveQuery : .writeQuery - let deleteConfirmation = BulkDeleteConfirmation(deletedRowCount: parent.changeManager.deletedRowIndices.count) + let deleteConfirmation = BulkDeleteConfirmation(deletedRowCount: parent.changeManager.deletedRowIDs.count) Task { [weak self, parent] in guard let self else { return } diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index c7638895c2..8757383d6f 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -62,12 +62,9 @@ final class RowEditingCoordinator { guard let result = addResult else { return } - if selectionPointsTheGrid { - parent.selectionState.indices = [result.rowIndex] - } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) - parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: result.rowIndex) + selectAndEditInsertedRow(result.rowID, tabId: tabId) } func deleteSelectedRows(indices: Set) { @@ -76,12 +73,8 @@ final class RowEditingCoordinator { tab.tableContext.isEditable, !indices.isEmpty else { return } - if parent.activeGridDisplayIDs != nil { - deleteFilteredRows(indices: indices, tab: tab, tabIndex: tabIndex) - return - } - let tabId = tab.id + let displayIDs = parent.activeGridDisplayIDs var deleteResult = RowOperationsManager.DeleteRowsResult( nextRowToSelect: -1, @@ -91,63 +84,14 @@ final class RowEditingCoordinator { parent.mutateActiveTableRows(for: tabId) { rows in let result = parent.rowOperationsManager.deleteSelectedRows( selectedIndices: indices, + displayIDs: displayIDs, tableRows: &rows ) deleteResult = result return result.delta } - let totalRows = parent.tabSessionRegistry.tableRows(for: tabId).count - if selectionPointsTheGrid { - if deleteResult.nextRowToSelect >= 0 && deleteResult.nextRowToSelect < totalRows { - parent.selectionState.indices = [deleteResult.nextRowToSelect] - } else { - parent.selectionState.indices.removeAll() - } - } - - parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - - if !deleteResult.physicallyRemovedIndices.isEmpty { - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta) - } else { - parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() - } - } - - private func deleteFilteredRows(indices: Set, tab: QueryTab, tabIndex: Int) { - let tabId = tab.id - let displayIDs = parent.activeGridDisplayIDs - let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) - - var existingRows: [(displayIndex: Int, originalRow: [PluginCellValue])] = [] - var insertedStorageIndices: [Int] = [] - for displayIndex in indices { - guard let storageIndex = DisplayRowMapping.rowIndex( - forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows - ) else { continue } - let row = tableRows.rows[storageIndex] - if row.id.isInserted { - insertedStorageIndices.append(storageIndex) - } else if !parent.changeManager.isRowDeleted(displayIndex) { - existingRows.append((displayIndex: displayIndex, originalRow: Array(row.values))) - } - } - - guard !existingRows.isEmpty || !insertedStorageIndices.isEmpty else { return } - - var deleteResult = RowOperationsManager.DeleteRowsResult( - nextRowToSelect: -1, physicallyRemovedIndices: [], delta: .none - ) - parent.mutateActiveTableRows(for: tabId) { rows in - let result = parent.rowOperationsManager.deleteRows( - existingRows: existingRows, - insertedStorageIndices: insertedStorageIndices, - tableRows: &rows - ) - deleteResult = result - return result.delta - } + guard deleteResult.stagedRowCount > 0 else { return } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } @@ -160,10 +104,10 @@ final class RowEditingCoordinator { guard selectionPointsTheGrid else { return } let displayCount = parent.activeGridDisplayIDs?.count ?? parent.tabSessionRegistry.tableRows(for: tabId).count - if let minSelected = indices.min(), displayCount > 0 { - parent.selectionState.indices = [min(minSelected, displayCount - 1)] + if deleteResult.nextRowToSelect >= 0 && deleteResult.nextRowToSelect < displayCount { + parent.selectionState.indices = [deleteResult.nextRowToSelect] } else { - parent.selectionState.indices = [] + parent.selectionState.indices.removeAll() } } @@ -171,45 +115,14 @@ final class RowEditingCoordinator { guard !parent.safeModeLevel.blocksAllWrites, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tableContext.isEditable, - tab.tableContext.tableName != nil, - parent.tabSessionRegistry.tableRows(for: tab.id).hasAuthoritativeSchema else { return } - - if parent.activeGridDisplayIDs != nil { - duplicateFilteredRow(displayIndex: index, tab: tab, tabIndex: tabIndex) - return - } - - let tabId = tab.id - guard index >= 0, index < parent.tabSessionRegistry.tableRows(for: tabId).count else { return } - - parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() - - var dupResult: RowOperationsManager.AddNewRowResult? - parent.mutateActiveTableRows(for: tabId) { rows in - let result = parent.rowOperationsManager.duplicateRow( - sourceRowIndex: index, - tableRows: &rows - ) - dupResult = result - return result?.delta ?? .none - } - - guard let result = dupResult else { return } - - if selectionPointsTheGrid { - parent.selectionState.indices = [result.rowIndex] - } - parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) - parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: result.rowIndex) - } + tab.tableContext.tableName != nil else { return } - private func duplicateFilteredRow(displayIndex: Int, tab: QueryTab, tabIndex: Int) { let tabId = tab.id let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) - guard let storageIndex = DisplayRowMapping.rowIndex( - forDisplay: displayIndex, displayIDs: parent.activeGridDisplayIDs, in: tableRows - ), storageIndex >= 0, storageIndex < tableRows.count else { return } + guard tableRows.hasAuthoritativeSchema, + let storageIndex = DisplayRowMapping.rowIndex( + forDisplay: index, displayIDs: parent.activeGridDisplayIDs, in: tableRows + ) else { return } parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() @@ -227,36 +140,30 @@ final class RowEditingCoordinator { parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) - - guard selectionPointsTheGrid else { return } - let displayCount = parent.activeGridDisplayIDs?.count - ?? parent.tabSessionRegistry.tableRows(for: tabId).count - let newDisplayIndex = displayCount - 1 - guard newDisplayIndex >= 0 else { return } - parent.selectionState.indices = [newDisplayIndex] - parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: newDisplayIndex) + selectAndEditInsertedRow(result.rowID, tabId: tabId) } - func undoInsertRow(at rowIndex: Int) { - guard let (tab, _) = parent.tabManager.selectedTabAndIndex else { return } - let tabId = tab.id + private func selectAndEditInsertedRow(_ rowID: RowID, tabId: UUID) { + guard selectionPointsTheGrid, + let displayIndex = displayIndex(of: rowID, tabId: tabId) else { return } + parent.selectionState.indices = [displayIndex] + parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: displayIndex) + } - var undoResult = RowOperationsManager.UndoInsertRowResult( - adjustedSelection: parent.selectionState.indices, - delta: .none + private func displayIndex(of rowID: RowID, tabId: UUID) -> Int? { + DisplayRowMapping.displayIndex( + forRowID: rowID, + displayIDs: parent.activeGridDisplayIDs, + in: parent.tabSessionRegistry.tableRows(for: tabId) ) - parent.mutateActiveTableRows(for: tabId) { rows in - let result = parent.rowOperationsManager.undoInsertRow( - at: rowIndex, - tableRows: &rows, - selectedIndices: parent.selectionState.indices - ) - undoResult = result - return result.delta - } + } - parent.selectionState.indices = undoResult.adjustedSelection - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(undoResult.delta) + private func displayIndices(of rowIDs: Set, tabId: UUID) -> Set { + guard let displayIDs = parent.activeGridDisplayIDs else { + let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) + return Set(rowIDs.compactMap { tableRows.index(of: $0) }) + } + return Set(displayIDs.indices.filter { rowIDs.contains(displayIDs[$0]) }) } func handleUndoResult(_ result: UndoResult) { @@ -265,11 +172,8 @@ final class RowEditingCoordinator { let tabId = tab.id var application = RowOperationsManager.UndoApplicationResult(adjustedSelection: nil, delta: .none) - let displayIDs = parent.activeGridDisplayIDs parent.mutateActiveTableRows(for: tabId) { rows in - let applied = parent.rowOperationsManager.applyUndoResult( - result, displayIDs: displayIDs, tableRows: &rows - ) + let applied = parent.rowOperationsManager.applyUndoResult(result, tableRows: &rows) application = applied return applied.delta } @@ -341,7 +245,7 @@ final class RowEditingCoordinator { guard !pasteResult.pastedRows.isEmpty else { return } - let newIndices = Set(pasteResult.pastedRows.map { $0.rowIndex }) + let newIndices = displayIndices(of: Set(pasteResult.pastedRows.map(\.rowID)), tabId: tabId) if selectionPointsTheGrid { parent.selectionState.indices = newIndices } diff --git a/TablePro/Core/CrossEngine/CrossEngineStructureTranslator.swift b/TablePro/Core/CrossEngine/CrossEngineStructureTranslator.swift index 4088c08648..dfa8ef8c6b 100644 --- a/TablePro/Core/CrossEngine/CrossEngineStructureTranslator.swift +++ b/TablePro/Core/CrossEngine/CrossEngineStructureTranslator.swift @@ -46,7 +46,8 @@ internal enum CrossEngineStructureTranslator { internal static func translate( _ snapshot: TableStructureSnapshot, from source: DatabaseType, - to target: DatabaseType + to target: DatabaseType, + targetServerVersion: String? = nil ) -> Result { let targetFamily = SQLTypeFamily.of(target) guard SQLTypeFamily.needsTranslation(from: source, to: target) else { @@ -61,6 +62,9 @@ internal enum CrossEngineStructureTranslator { } let sourceFamily = SQLTypeFamily.of(source) + let jsonColumnType = PostgreSQLServerVersion.jsonColumnType( + for: target, serverVersion: targetServerVersion + ) var notes: [CrossEngineConversionNote] = [] var sourceKindsByColumn: [String: CanonicalTypeKind] = [:] var kindsByColumn: [String: CanonicalTypeKind] = [:] @@ -76,6 +80,7 @@ internal enum CrossEngineStructureTranslator { table: snapshot.name, from: sourceFamily, to: targetFamily, + jsonColumnType: jsonColumnType, isKeyColumn: keyColumns.contains(column.name.lowercased()) ) columns.append(outcome.column) @@ -145,10 +150,11 @@ internal enum CrossEngineStructureTranslator { table: String, from sourceFamily: SQLTypeFamily, to targetFamily: SQLTypeFamily, + jsonColumnType: PostgreSQLJSONColumnType, isKeyColumn: Bool ) -> ColumnOutcome { let canonical = SQLTypeParser.parse(column.dataType, family: sourceFamily) - var rendered = SQLTypeRenderer.render(canonical, family: targetFamily) + var rendered = SQLTypeRenderer.render(canonical, family: targetFamily, jsonColumnType: jsonColumnType) var notes: [CrossEngineConversionNote] = [] if isKeyColumn, let bounded = boundedKeyType(rendered, kind: canonical.kind, family: targetFamily) { diff --git a/TablePro/Core/CrossEngine/SQLTypeFamily.swift b/TablePro/Core/CrossEngine/SQLTypeFamily.swift index cae295d6cd..d2c9ed8cdf 100644 --- a/TablePro/Core/CrossEngine/SQLTypeFamily.swift +++ b/TablePro/Core/CrossEngine/SQLTypeFamily.swift @@ -43,6 +43,7 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { "MySQL": .mysql, "MariaDB": .mysql, "TiDB": .mysql, + "OceanBase": .mysql, "PostgreSQL": .postgres, "Redshift": .postgres, "CockroachDB": .postgres, diff --git a/TablePro/Core/CrossEngine/SQLTypeRenderer+BundledFamilies.swift b/TablePro/Core/CrossEngine/SQLTypeRenderer+BundledFamilies.swift index 8be0499f10..d125c792a4 100644 --- a/TablePro/Core/CrossEngine/SQLTypeRenderer+BundledFamilies.swift +++ b/TablePro/Core/CrossEngine/SQLTypeRenderer+BundledFamilies.swift @@ -149,7 +149,10 @@ internal extension SQLTypeRenderer { // MARK: - PostgreSQL - static func postgres(_ type: CanonicalColumnType) -> RenderedColumnType { + static func postgres( + _ type: CanonicalColumnType, + jsonColumnType: PostgreSQLJSONColumnType = .jsonb + ) -> RenderedColumnType { switch type.kind { case .boolean: return RenderedColumnType(spelling: "BOOLEAN") @@ -183,7 +186,7 @@ internal extension SQLTypeRenderer { case .uuid: return RenderedColumnType(spelling: "UUID") case .json: - return RenderedColumnType(spelling: "JSONB") + return postgresJSON(type, jsonColumnType: jsonColumnType) case .xml: return RenderedColumnType(spelling: "XML") case .enumeration(let values): @@ -206,9 +209,10 @@ internal extension SQLTypeRenderer { reason: noEquivalent(type.sourceSpelling, as: String(localized: "text")) ) case .array(let element): - let inner = postgres(CanonicalColumnType( - kind: element, isUnsigned: type.isUnsigned, sourceSpelling: type.sourceSpelling - )) + let inner = postgres( + CanonicalColumnType(kind: element, isUnsigned: type.isUnsigned, sourceSpelling: type.sourceSpelling), + jsonColumnType: jsonColumnType + ) return RenderedColumnType(spelling: "\(inner.spelling)[]", fidelity: inner.fidelity, reason: inner.reason) case .unsupported: return RenderedColumnType( @@ -218,6 +222,19 @@ internal extension SQLTypeRenderer { } } + private static func postgresJSON( + _ type: CanonicalColumnType, + jsonColumnType: PostgreSQLJSONColumnType + ) -> RenderedColumnType { + guard jsonColumnType == .text else { + return RenderedColumnType(spelling: jsonColumnType.rawValue) + } + return RenderedColumnType( + spelling: jsonColumnType.rawValue, fidelity: .approximated, + reason: noEquivalent(type.sourceSpelling, as: String(localized: "text")) + ) + } + /// PostgreSQL has no unsigned integers, so an unsigned source widens by one step and the widest /// becomes a `NUMERIC`. Kept as the same width, a `BIGINT UNSIGNED` above 2^63 fails on the row /// that first exceeds it, which on a long copy is minutes in. diff --git a/TablePro/Core/CrossEngine/SQLTypeRenderer.swift b/TablePro/Core/CrossEngine/SQLTypeRenderer.swift index c5a97e6dcb..a25ea2cdd6 100644 --- a/TablePro/Core/CrossEngine/SQLTypeRenderer.swift +++ b/TablePro/Core/CrossEngine/SQLTypeRenderer.swift @@ -20,10 +20,14 @@ import Foundation internal enum SQLTypeRenderer { - internal static func render(_ type: CanonicalColumnType, family: SQLTypeFamily) -> RenderedColumnType { + internal static func render( + _ type: CanonicalColumnType, + family: SQLTypeFamily, + jsonColumnType: PostgreSQLJSONColumnType = .jsonb + ) -> RenderedColumnType { switch family { case .mysql: return mysql(type) - case .postgres: return postgres(type) + case .postgres: return postgres(type, jsonColumnType: jsonColumnType) case .sqlite: return sqlite(type) case .mssql: return mssql(type) case .oracle: return oracle(type) diff --git a/TablePro/Core/DataGrid/DataGridDisplayState.swift b/TablePro/Core/DataGrid/DataGridDisplayState.swift index ddb718ca36..b4f23d2920 100644 --- a/TablePro/Core/DataGrid/DataGridDisplayState.swift +++ b/TablePro/Core/DataGrid/DataGridDisplayState.swift @@ -50,4 +50,5 @@ final class DataGridDisplayState { /// reports a schema and a format change and clears the text it was just handed. var identitySchema: ColumnIdentitySchema? var displayFormats: [ValueDisplayFormat?]? + var highlightRuleSetKey: HighlightRuleSet.Key? } diff --git a/TablePro/Core/DataGrid/RowDisplayBox.swift b/TablePro/Core/DataGrid/RowDisplayBox.swift index b4f475587c..99f39e1bd2 100644 --- a/TablePro/Core/DataGrid/RowDisplayBox.swift +++ b/TablePro/Core/DataGrid/RowDisplayBox.swift @@ -25,6 +25,7 @@ final class RowDisplayCache { } private var storage: [RowID: Entry] = [:] + private var highlights: [RowID: RowHighlight] = [:] private var insertionOrder: [RowID] = [] private var insertionHead: Int = 0 private var totalCost: Int = 0 @@ -52,8 +53,28 @@ final class RowDisplayCache { evictIfNeeded() } + func highlight(forID id: RowID) -> RowHighlight? { + highlights[id] + } + + func setHighlight(_ highlight: RowHighlight, forID id: RowID) { + if highlights.count >= countLimit { + highlights.removeAll(keepingCapacity: true) + } + highlights[id] = highlight + } + + func clearHighlight(forID id: RowID) { + highlights.removeValue(forKey: id) + } + + func clearHighlights() { + highlights.removeAll(keepingCapacity: true) + } + func removeAll() { storage.removeAll(keepingCapacity: true) + highlights.removeAll(keepingCapacity: true) insertionOrder.removeAll(keepingCapacity: true) insertionHead = 0 totalCost = 0 @@ -64,6 +85,7 @@ final class RowDisplayCache { /// whose content changed in place keeps its id and would otherwise be served /// its pre-edit text. func clearValues(forID id: RowID) { + highlights.removeValue(forKey: id) guard let existing = storage[id] else { return } totalCost -= existing.cost for index in existing.box.values.indices { diff --git a/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift b/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift index 9d97db452d..cdb9df9d42 100644 --- a/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift +++ b/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift @@ -205,7 +205,7 @@ struct RewindPlanner { } guard !cellChanges.isEmpty else { return [] } return try factory.statements( - for: [RowChange(rowIndex: 0, type: .update, cellChanges: cellChanges, originalRow: postImage)] + for: [RowChange(rowID: .existing(0), type: .update, cellChanges: cellChanges, originalRow: postImage)] ) case .delete: guard let preImage = operation.preImage else { return [] } @@ -213,8 +213,8 @@ struct RewindPlanner { case .insert: guard let postImage = operation.postImage else { return [] } return try factory.statements( - for: [RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: postImage)], - deletedRowIndices: [0] + for: [RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: postImage)], + deletedRowIDs: [.existing(0)] ) } } diff --git a/TablePro/Core/DataWrite/RowChangeStatementFactory.swift b/TablePro/Core/DataWrite/RowChangeStatementFactory.swift index 2bd64c93da..940e6dc91d 100644 --- a/TablePro/Core/DataWrite/RowChangeStatementFactory.swift +++ b/TablePro/Core/DataWrite/RowChangeStatementFactory.swift @@ -46,23 +46,23 @@ struct RowChangeStatementFactory { func statements( for changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]] = [:], - deletedRowIndices: Set = [], - insertedRowIndices: Set = [] + insertedRowData: [RowID: [PluginCellValue]] = [:], + deletedRowIDs: Set = [], + insertedRowIDs: Set = [] ) throws -> [ParameterizedStatement] { if let pluginStatements = pluginGeneratedStatements( for: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ) { return pluginStatements } return try attributedHostStatements( for: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ).map(\.statement) } @@ -72,40 +72,40 @@ struct RowChangeStatementFactory { /// which rows went into which statement and a guessed count is worse than no count. func attributedStatements( for changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]] = [:], - deletedRowIndices: Set = [], - insertedRowIndices: Set = [] + insertedRowData: [RowID: [PluginCellValue]] = [:], + deletedRowIDs: Set = [], + insertedRowIDs: Set = [] ) throws -> [AttributedStatement]? { if pluginGeneratedStatements( for: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ) != nil { return nil } return try attributedHostStatements( for: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ) } private func attributedHostStatements( for changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]], - deletedRowIndices: Set, - insertedRowIndices: Set + insertedRowData: [RowID: [PluginCellValue]], + deletedRowIDs: Set, + insertedRowIDs: Set ) throws -> [AttributedStatement] { let statements = try hostGenerator().generateAttributedStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ) - try validate(statements.map(\.statement), against: changes, deletedRowIndices: deletedRowIndices) - let deletableCount = changes.count { $0.type == .delete && deletedRowIndices.contains($0.rowIndex) } + try validate(statements.map(\.statement), against: changes, deletedRowIDs: deletedRowIDs) + let deletableCount = changes.count { $0.type == .delete && deletedRowIDs.contains($0.rowID) } let identifiedDeletes = statements.filter { $0.kind == .delete }.reduce(0) { $0 + $1.rowCount } if identifiedDeletes < deletableCount { throw DataWriteError.rowsNotIdentifiable(tableName, .delete) @@ -140,12 +140,13 @@ struct RowChangeStatementFactory { let generator = try hostGenerator() var statements: [ParameterizedStatement] = [] for (offset, row) in rows.enumerated() { - let change = RowChange(rowIndex: offset, type: .insert, cellChanges: [], originalRow: row) + let rowID = RowID.existing(offset) + let change = RowChange(rowID: rowID, type: .insert, cellChanges: [], originalRow: row) let generated = generator.generateStatements( from: [change], - insertedRowData: [offset: row], - deletedRowIndices: [], - insertedRowIndices: [offset] + insertedRowData: [rowID: row], + deletedRowIDs: [], + insertedRowIDs: [rowID] ) guard let statement = generated.first else { throw DataWriteError.statementGenerationFailed(tableName) @@ -159,30 +160,35 @@ struct RowChangeStatementFactory { /// `SQLStatementGenerator`, which is what decides whether the host may fall back. var pluginOwnsStatementGeneration: Bool { pluginGeneratedStatements( - for: [RowChange(rowIndex: 0, type: .update, cellChanges: [], originalRow: nil)], + for: [RowChange(rowID: .existing(0), type: .update, cellChanges: [], originalRow: nil)], insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) != nil } private func pluginGeneratedStatements( for changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]], - deletedRowIndices: Set, - insertedRowIndices: Set + insertedRowData: [RowID: [PluginCellValue]], + deletedRowIDs: Set, + insertedRowIDs: Set ) -> [ParameterizedStatement]? { guard let pluginDriver else { return nil } - let pluginChanges = changes.map(PluginRowChange.init(_:)) + let keyed = PluginKeyedChanges( + changes: changes, + insertedRowData: insertedRowData, + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs + ) guard let statements = pluginDriver.generateStatements( table: tableName, schema: schemaName, columns: columns, primaryKeyColumns: primaryKeyColumns, - changes: pluginChanges, - insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + changes: keyed.changes, + insertedRowData: keyed.insertedRowData, + deletedRowIndices: keyed.deletedRowIndices, + insertedRowIndices: keyed.insertedRowIndices ) else { return nil } return statements.map { ParameterizedStatement(sql: $0.statement, parameters: $0.parameters.map(\.asAny)) @@ -208,7 +214,7 @@ struct RowChangeStatementFactory { private func validate( _ statements: [ParameterizedStatement], against changes: [RowChange], - deletedRowIndices: Set + deletedRowIDs: Set ) throws { let expectedUpdates = changes.count(where: { $0.type == .update }) let actualUpdates = statements.count(where: { $0.sql.hasPrefix("UPDATE") }) @@ -216,17 +222,46 @@ struct RowChangeStatementFactory { throw DataWriteError.rowsNotIdentifiable(tableName, .update) } - let deletable = changes.filter { $0.type == .delete && deletedRowIndices.contains($0.rowIndex) } + let deletable = changes.filter { $0.type == .delete && deletedRowIDs.contains($0.rowID) } if !deletable.isEmpty, deletable.allSatisfy({ $0.originalRow == nil }) { throw DataWriteError.rowsNotIdentifiable(tableName, .delete) } } } +struct PluginKeyedChanges { + let changes: [PluginRowChange] + let insertedRowData: [Int: [PluginCellValue]] + let deletedRowIndices: Set + let insertedRowIndices: Set + + init( + changes: [RowChange], + insertedRowData: [RowID: [PluginCellValue]], + deletedRowIDs: Set, + insertedRowIDs: Set + ) { + var keys: [RowID: Int] = [:] + for change in changes where keys[change.rowID] == nil { + keys[change.rowID] = keys.count + } + self.changes = changes.compactMap { change in + keys[change.rowID].map { PluginRowChange(change, key: $0) } + } + self.insertedRowData = Dictionary( + uniqueKeysWithValues: insertedRowData.compactMap { rowID, values in + keys[rowID].map { ($0, values) } + } + ) + self.deletedRowIndices = Set(deletedRowIDs.compactMap { keys[$0] }) + self.insertedRowIndices = Set(insertedRowIDs.compactMap { keys[$0] }) + } +} + private extension PluginRowChange { - init(_ change: RowChange) { + init(_ change: RowChange, key: Int) { self.init( - rowIndex: change.rowIndex, + rowIndex: key, type: { switch change.type { case .insert: return .insert diff --git a/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift b/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift index 0017a91957..d6da086be3 100644 --- a/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift +++ b/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift @@ -22,9 +22,9 @@ enum RowWriteOperationBuilder { static func operations( from changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]], - deletedRowIndices: Set, - insertedRowIndices: Set, + insertedRowData: [RowID: [PluginCellValue]], + deletedRowIDs: Set, + insertedRowIDs: Set, target: DataWriteTarget, columns: [String], primaryKeyColumns: [String], @@ -40,16 +40,16 @@ enum RowWriteOperationBuilder { containsTableOperation: containsTableOperation ) case .delete: - guard deletedRowIndices.contains(change.rowIndex) else { return nil } + guard deletedRowIDs.contains(change.rowID) else { return nil } return delete( change, target: target, columns: columns, primaryKeyColumns: primaryKeyColumns, containsTableOperation: containsTableOperation ) case .insert: - guard insertedRowIndices.contains(change.rowIndex) else { return nil } + guard insertedRowIDs.contains(change.rowID) else { return nil } return insert( - change, values: insertedRowData[change.rowIndex], + change, values: insertedRowData[change.rowID], target: target, columns: columns, primaryKeyColumns: primaryKeyColumns, containsTableOperation: containsTableOperation diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 286c439326..c17ffac981 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -19,6 +19,8 @@ protocol DatabaseDriver: AnyObject, Sendable { /// Current connection status var status: ConnectionStatus { get } + var hasLostConnection: Bool { get } + /// Server version string (e.g., "8.0.35" for MySQL) /// Optional - not all drivers may implement this var serverVersion: String? { get } @@ -128,6 +130,9 @@ protocol DatabaseDriver: AnyObject, Sendable { var triggerEditUsesReplace: Bool { get } var supportsTransactionalDDL: Bool { get } + var unsupportedStructureColumnFields: Set { get } + var unsupportedIndexTypes: Set { get } + /// Fetch foreign keys for all tables in the current database/schema in bulk. /// Default implementation falls back to per-table fetchForeignKeys. func fetchAllForeignKeys() async throws -> [String: [ForeignKeyInfo]] @@ -159,6 +164,10 @@ protocol DatabaseDriver: AnyObject, Sendable { /// Empty on an engine whose CREATE TABLE carries them inline. Default returns empty. func fetchIndexDDL(table: String) async throws -> [String] + /// The COMMENT statements that reattach this relation's comment and its column comments. Empty + /// on an engine whose CREATE TABLE carries them inline. Default returns empty. + func fetchCommentDDL(table: String) async throws -> [String] + /// Fetch dependent type definitions (e.g., PostgreSQL enum types) for a table. /// Returns array of (typeName, labels) pairs. Default returns empty. func fetchDependentTypes(forTable table: String) async throws -> [(name: String, labels: [String])] @@ -236,12 +245,39 @@ protocol DatabaseDriver: AnyObject, Sendable { // MARK: - Maintenance - /// Returns the list of supported maintenance operations (e.g. "VACUUM", "ANALYZE"). - /// Returns nil if maintenance is not supported. - func supportedMaintenanceOperations() -> [String]? + /// The maintenance operations this connection offers, each with the object kinds it may name, its + /// scope and its options. Returns nil if maintenance is not supported. + /// + /// Descriptors rather than names, because the menu has to decide whether an operation applies to + /// the object the user clicked: PostgreSQL skips a `VACUUM` on a view with a WARNING and the + /// success command tag `VACUUM`, and refuses a `REINDEX` on one outright. + func maintenanceOperations() -> [PluginMaintenanceOperation]? + + /// Generates SQL statements for a maintenance operation. The single source of the statement, so + /// the confirmation sheet previews this rather than writing its own copy of the SQL. + /// + /// A nil `schema` means the caller genuinely has none to offer. Everything in the app does, and + /// passes it: PostgreSQL resolves a bare name against `pg_temp` first, so a temp table of the + /// same name is what got maintained. + func maintenanceStatements( + operation: String, + table: String?, + schema: String?, + options: [String: String] + ) -> [String]? + + // MARK: - Object Comments and Materialized Views + + /// Nil for an object kind the engine cannot comment on. Takes the object's own schema, never + /// the connection's current one, because the object named may live anywhere in the tree. + func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? - /// Generates SQL statements for a maintenance operation. - func maintenanceStatements(operation: String, table: String?, options: [String: String]) -> [String]? + func refreshMaterializedViewStatement(name: String, schema: String?, concurrently: Bool) -> String? + + func concurrentRefreshAvailability( + materializedView: String, + schema: String? + ) async throws -> PluginConcurrentRefreshAvailability? // MARK: - Query Cancellation @@ -329,6 +365,8 @@ extension DatabaseDriver { func fetchIndexDDL(table: String) async throws -> [String] { [] } + func fetchCommentDDL(table: String) async throws -> [String] { [] } + func resolveQueryCompletionProfile( databaseTypeId: String, base: QueryCompletionProfile @@ -377,6 +415,9 @@ extension DatabaseDriver { var triggerEditUsesReplace: Bool { false } var supportsTransactionalDDL: Bool { false } + var unsupportedStructureColumnFields: Set { [] } + var unsupportedIndexTypes: Set { [] } + func ping() async throws { _ = try await execute(query: "SELECT 1") } @@ -549,8 +590,26 @@ extension DatabaseDriver { try await fetchFilteredRowCount(table: table, filters: filters, logicMode: logicMode) } - func supportedMaintenanceOperations() -> [String]? { nil } - func maintenanceStatements(operation: String, table: String?, options: [String: String]) -> [String]? { nil } + func maintenanceOperations() -> [PluginMaintenanceOperation]? { nil } + func maintenanceStatements( + operation: String, + table: String?, + schema: String?, + options: [String: String] + ) -> [String]? { nil } + + func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { + nil + } + + func refreshMaterializedViewStatement(name: String, schema: String?, concurrently: Bool) -> String? { nil } + + func concurrentRefreshAvailability( + materializedView: String, + schema: String? + ) async throws -> PluginConcurrentRefreshAvailability? { + nil + } /// Default: no schema support (MySQL/SQLite don't use schemas in the same way) func fetchSchemas() async throws -> [String] { [] } @@ -595,6 +654,8 @@ extension DatabaseDriver { var supportsTransactions: Bool { true } + var hasLostConnection: Bool { false } + func cancelQuery() throws { } diff --git a/TablePro/Core/Database/DatabaseManager+Verification.swift b/TablePro/Core/Database/DatabaseManager+Verification.swift index ff3b722db4..e33c3ef870 100644 --- a/TablePro/Core/Database/DatabaseManager+Verification.swift +++ b/TablePro/Core/Database/DatabaseManager+Verification.swift @@ -71,7 +71,13 @@ extension DatabaseManager { /// No record means no answer, which is a reason to ask rather than a reason to assume. /// That is also what makes waking from sleep work: it throws every answer away, and the /// next thing anyone does pays for one check. - if let last = lastVerifiedAt[connectionId], ConnectionHealthCheck.isFresh(last) { return } + /// A driver that reported a lost connection has already answered the question a ping + /// would ask, so its stamp says nothing about the socket it is holding now. + if let last = lastVerifiedAt[connectionId], + ConnectionHealthCheck.isFresh(last), + !driver.hasLostConnection { + return + } try? await verificationDedup.execute(key: connectionId) { await self.runVerification(connectionId, driver: driver) diff --git a/TablePro/Core/Database/DatabaseObjectToolEligibility.swift b/TablePro/Core/Database/DatabaseObjectToolEligibility.swift new file mode 100644 index 0000000000..b91b31bcdd --- /dev/null +++ b/TablePro/Core/Database/DatabaseObjectToolEligibility.swift @@ -0,0 +1,53 @@ +// +// DatabaseObjectToolEligibility.swift +// TablePro +// + +import Foundation + +/// Which of the per-object commands (Show DDL, Copy DDL, Refresh Materialized View, Edit Comment) +/// apply to a row. The sidebar and the menu bar ask the same functions, so the two can never offer +/// a command to a different set of objects; the sidebar omits what the menu bar disables. +enum DatabaseObjectToolEligibility { + /// What the connection's driver can do, read once per menu build rather than per item. + /// + /// Derived by asking the driver's own statement hooks, so a driver cannot declare a capability + /// it has no statement for. + struct Support: Equatable { + var canRefreshMaterializedViews = false + var commentableTypes: Set = [] + + static let none = Support() + + @MainActor + static func of(_ driver: DatabaseDriver?) -> Support { + guard let driver else { return .none } + let commentable = TableInfo.TableType.allCases.filter { type in + driver.objectCommentStatement(name: "t", objectType: type.rawValue, schema: "s", comment: nil) != nil + } + return Support( + canRefreshMaterializedViews: driver.refreshMaterializedViewStatement( + name: "t", schema: "s", concurrently: false + ) != nil, + commentableTypes: Set(commentable) + ) + } + } + + /// A view's source is its definition rather than its columns, which is what the DDL viewer + /// shows. Offered read-only too: reading a definition changes nothing. + static func canShowDDL(_ type: TableInfo.TableType?) -> Bool { + guard let type else { return false } + return DatabaseObjectKind(tableType: type) != nil + } + + static func canRefresh(_ type: TableInfo.TableType?, support: Support, isReadOnly: Bool) -> Bool { + guard !isReadOnly, type == .materializedView else { return false } + return support.canRefreshMaterializedViews + } + + static func canEditComment(_ type: TableInfo.TableType?, support: Support, isReadOnly: Bool) -> Bool { + guard !isReadOnly, let type else { return false } + return support.commentableTypes.contains(type) + } +} diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index 094c803060..f34bc5eb7f 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -27,16 +27,25 @@ struct FilterSQLGenerator { private let dialect: SQLDialectDescriptor private let quoteIdentifierFn: (String) -> String private let columnTypesByName: [String: ColumnType] + private let stringLiteralPrefix: String init( dialect: SQLDialectDescriptor, columns: [String] = [], columnTypes: [ColumnType] = [], - quoteIdentifier: ((String) -> String)? = nil + quoteIdentifier: ((String) -> String)? = nil, + stringLiteralPrefix: String = "" ) { self.dialect = dialect self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) self.columnTypesByName = ColumnTypeSQLQuoting.lookupByName(columns: columns, columnTypes: columnTypes) + self.stringLiteralPrefix = stringLiteralPrefix + } + + /// The one place a string literal is spelled, so the engine's prefix cannot be forgotten on + /// one arm and applied on another. + private func quotedLiteral(_ escapedBody: String) -> String { + "\(stringLiteralPrefix)'\(escapedBody)'" } // MARK: - Public API @@ -147,7 +156,7 @@ struct FilterSQLGenerator { case .regex: let operand = patternOperand(quotedColumn, columnType: columnType) guard dialect.regexSyntax != .unsupported else { - let pattern = "'%\(escapeSQLQuote(filter.value))%'" + let pattern = quotedLiteral("%\(escapeSQLQuote(filter.value))%") return "\(folding.foldingLikeOperand(operand)) \(folding.likeKeyword) " + folding.foldingLikeOperand(pattern) } @@ -292,7 +301,7 @@ struct FilterSQLGenerator { negated: Bool, folding: PluginSQLCaseFolding ) -> String { - let quotedPattern = "'\(escapeSQLQuote(pattern))'" + let quotedPattern = quotedLiteral(escapeSQLQuote(pattern)) let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword let operand = folding.foldingLikeOperand(column) return "\(operand) \(keyword) \(folding.foldingLikeOperand(quotedPattern))\(likeEscapeClause)" @@ -357,7 +366,7 @@ struct FilterSQLGenerator { guard ignoresCase else { return "match(\(column), '\(escapedPattern)')" } return "match(\(column), '(?i)\(escapedPattern)')" case .unsupported: - return "\(column) LIKE '%\(escapedPattern)%'" + return "\(column) LIKE \(quotedLiteral("%\(escapedPattern)%"))" } } @@ -379,7 +388,7 @@ struct FilterSQLGenerator { return .value(trimmed) } - return .value("'\(escapeStringValue(trimmed))'") + return .value(quotedLiteral(escapeStringValue(trimmed))) } private func booleanLiteral(for value: String, columnType: ColumnType?) -> String? { diff --git a/TablePro/Core/Database/ForeignKeyLookupQuery.swift b/TablePro/Core/Database/ForeignKeyLookupQuery.swift index 895e12594c..747a48c5ef 100644 --- a/TablePro/Core/Database/ForeignKeyLookupQuery.swift +++ b/TablePro/Core/Database/ForeignKeyLookupQuery.swift @@ -27,6 +27,7 @@ enum ForeignKeyLookupQuery { label: ForeignKeyLookupColumn?, searchTerm: String, dialect: SQLDialectDescriptor, + stringLiteralPrefix: String, quoteIdentifier: @escaping (String) -> String ) -> String? { let selected = selectedColumns(key: key, label: label) @@ -35,7 +36,8 @@ enum ForeignKeyLookupQuery { dialect: dialect, columns: selected.map(\.name), columnTypes: selected.map(\.type), - quoteIdentifier: quoteIdentifier + quoteIdentifier: quoteIdentifier, + stringLiteralPrefix: stringLiteralPrefix ) /// A referenced column may be a nullable `UNIQUE` one, and ascending order puts its NULLs diff --git a/TablePro/Core/Database/ForeignKeyPreviewQuery.swift b/TablePro/Core/Database/ForeignKeyPreviewQuery.swift index 9f2af3f015..c94fc7b8e1 100644 --- a/TablePro/Core/Database/ForeignKeyPreviewQuery.swift +++ b/TablePro/Core/Database/ForeignKeyPreviewQuery.swift @@ -17,9 +17,11 @@ enum ForeignKeyPreviewQuery { quotedTable: String, quotedColumn: String, escapedValue: String, + stringLiteralPrefix: String, dialect: SQLDialectDescriptor? ) -> String { - "SELECT * FROM \(quotedTable) WHERE \(quotedColumn) = '\(escapedValue)' \(limitClause(dialect: dialect))" + let literal = "\(stringLiteralPrefix)'\(escapedValue)'" + return "SELECT * FROM \(quotedTable) WHERE \(quotedColumn) = \(literal) \(limitClause(dialect: dialect))" } /// OFFSET/FETCH is part of ORDER BY in T-SQL and Oracle, so the dialect's filler clause travels diff --git a/TablePro/Core/Database/MaterializedViewRefreshing.swift b/TablePro/Core/Database/MaterializedViewRefreshing.swift new file mode 100644 index 0000000000..7179168faa --- /dev/null +++ b/TablePro/Core/Database/MaterializedViewRefreshing.swift @@ -0,0 +1,55 @@ +// +// MaterializedViewRefreshing.swift +// TablePro +// +// Recomputes a materialized view's rows through the execution gate. +// + +import Combine +import Foundation +import TableProPluginKit + +@MainActor +enum MaterializedViewRefreshing { + /// Nil where the engine has no refresh that leaves readers alone, so the prompt offers no + /// option at all rather than one that is always off. + static func concurrentRefreshAvailability( + of target: DatabaseObjectTarget + ) async throws -> PluginConcurrentRefreshAvailability? { + let name = target.name + let schema = target.schema + return try await DatabaseManager.shared.withMetadataDriver(scope: target.scope) { driver in + try await driver.concurrentRefreshAvailability(materializedView: name, schema: schema) + } + } + + /// Runs on its own connection wherever the engine has one to give, never inside a transaction a + /// query tab left open: a plain refresh holds an exclusive lock on the view until the + /// transaction it runs in ends, which inside the user's would be until they committed. + static func refresh( + _ target: DatabaseObjectTarget, + concurrently: Bool, + connection: DatabaseConnection, + gate: any ExecutionGate = ExecutionGateProvider.shared + ) async throws { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw DatabaseObjectCommandError.notConnected + } + guard let sql = driver.refreshMaterializedViewStatement( + name: target.name, + schema: target.schema, + concurrently: concurrently + ) else { + throw DatabaseObjectCommandError.unsupported + } + try await DatabaseObjectCommandRunner.run( + sql, + on: target, + connection: connection, + kind: .maintenance, + operationDescription: String(localized: "Refresh Materialized View"), + gate: gate + ) + AppCommands.shared.objectChanged.send(target.change(.rows)) + } +} diff --git a/TablePro/Core/Database/NativeDumpBatch.swift b/TablePro/Core/Database/NativeDumpBatch.swift index 6c7fe06cc8..bdd95d309d 100644 --- a/TablePro/Core/Database/NativeDumpBatch.swift +++ b/TablePro/Core/Database/NativeDumpBatch.swift @@ -195,7 +195,7 @@ final class NativeDumpBatch { case .running(_, _, let bytes, let total): state.bytesProcessed = bytes state.totalBytes = total ?? state.totalBytes - case .finished(_, _, let bytes): + case .finished(_, _, let bytes, _): record(item, .succeeded(bytes: bytes)) return case .failed(let message, _): diff --git a/TablePro/Core/Database/NativeDumpDescriptor.swift b/TablePro/Core/Database/NativeDumpDescriptor.swift index 45ac0bc370..88c354e7b1 100644 --- a/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -122,6 +122,14 @@ struct NativeDumpDescriptor: Sendable { /// from the binary's name, which is what `buildCommand` used to do. let needsCredentialsFile: Bool + internal let restoreExitPolicy: NativeDumpExitPolicy + + internal let requiresUntranslatedMessages: Bool + + /// Picks the binary to run for a known server version, when the engine's tools refuse + /// some servers. Nil leaves the plain PATH lookup in place. + let toolForServer: (@Sendable (_ binary: String, _ serverVersion: String?) -> NativeDumpToolSelection)? + let backupArguments: @Sendable (Request) -> [String] let restoreArguments: @Sendable (Request) -> [String] let environment: @Sendable (Request) -> [String: String] @@ -134,6 +142,9 @@ struct NativeDumpDescriptor: Sendable { restoreDelivery: OutputDelivery, exposesPasswordInArguments: Bool = false, needsCredentialsFile: Bool = false, + restoreExitPolicy: NativeDumpExitPolicy = .zeroExitOnly, + requiresUntranslatedMessages: Bool = false, + toolForServer: (@Sendable (_ binary: String, _ serverVersion: String?) -> NativeDumpToolSelection)? = nil, backupArguments: @escaping @Sendable (Request) -> [String], restoreArguments: @escaping @Sendable (Request) -> [String], environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] } @@ -145,6 +156,9 @@ struct NativeDumpDescriptor: Sendable { self.restoreDelivery = restoreDelivery self.exposesPasswordInArguments = exposesPasswordInArguments self.needsCredentialsFile = needsCredentialsFile + self.restoreExitPolicy = restoreExitPolicy + self.requiresUntranslatedMessages = requiresUntranslatedMessages + self.toolForServer = toolForServer self.backupArguments = backupArguments self.restoreArguments = restoreArguments self.environment = environment @@ -161,6 +175,10 @@ struct NativeDumpDescriptor: Sendable { func delivery(for kind: NativeDumpKind) -> OutputDelivery { kind == .backup ? backupDelivery : restoreDelivery } + + internal func exitPolicy(for kind: NativeDumpKind) -> NativeDumpExitPolicy { + kind == .backup ? .zeroExitOnly : restoreExitPolicy + } } /// Statements the engine already in front of the app runs for itself. diff --git a/TablePro/Core/Database/NativeDumpRegistry.swift b/TablePro/Core/Database/NativeDumpRegistry.swift index d69e319f6e..fe48df12b2 100644 --- a/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/TablePro/Core/Database/NativeDumpRegistry.swift @@ -34,8 +34,10 @@ enum NativeDumpRegistry { formatId: String? = nil ) -> NativeDumpDescriptor? { switch type { - case .postgresql, .redshift: - return postgres + case .postgresql: + return postgres(toolForServer: postgresToolForServer) + case .redshift: + return postgres(toolForServer: nil) case .mysql, .mariadb: return mysql case .mongodb: @@ -70,7 +72,15 @@ enum NativeDumpRegistry { // MARK: - PostgreSQL - private static var postgres: NativeDumpDescriptor { + /// Redshift reports 8.0.2, which no `pg_dump` release claims to support, so only PostgreSQL + /// itself is matched to the server. + static let postgresToolForServer: @Sendable (String, String?) -> NativeDumpToolSelection = { binary, serverVersion in + PostgreSQLDumpToolLocator.select(binary: binary, serverVersion: serverVersion) + } + + private static func postgres( + toolForServer: (@Sendable (String, String?) -> NativeDumpToolSelection)? + ) -> NativeDumpDescriptor { NativeDumpDescriptor( mechanism: .commandLineTool( NativeDumpDescriptor.CommandLineTool( @@ -79,6 +89,9 @@ enum NativeDumpRegistry { installHint: String(localized: "Install it with `brew install libpq` and link it."), backupDelivery: .toolWritesFile, restoreDelivery: .toolWritesFile, + restoreExitPolicy: .toleratesUnrecognizedSessionSettings, + requiresUntranslatedMessages: true, + toolForServer: toolForServer, backupArguments: { request in connectionFlags(request) + ["-Fc", "-d", request.database] diff --git a/TablePro/Core/Database/NativeDumpService.swift b/TablePro/Core/Database/NativeDumpService.swift index de81dee115..190a9fcf6b 100644 --- a/TablePro/Core/Database/NativeDumpService.swift +++ b/TablePro/Core/Database/NativeDumpService.swift @@ -26,7 +26,7 @@ enum NativeDumpState: Equatable { case idle case running(database: String, fileURL: URL, bytesProcessed: Int64, totalBytes: Int64?) case cancelling - case finished(database: String, fileURL: URL, bytesProcessed: Int64) + case finished(database: String, fileURL: URL, bytesProcessed: Int64, skippedSettings: [String] = []) /// A restore that fails part way through has already replayed some of the dump, and the /// target is left in whatever state that reached. A backup writes only to its own file, which /// is removed, so nothing of the user's is touched. @@ -41,6 +41,7 @@ enum NativeDumpError: LocalizedError, Equatable { case alreadyRunning case sourceUnreadable case engineStatementUnavailable + case incompatibleTool(message: String) var errorDescription: String? { switch self { @@ -60,6 +61,22 @@ enum NativeDumpError: LocalizedError, Equatable { return String(localized: "The selected backup file is not readable.") case .engineStatementUnavailable: return String(localized: "TablePro could not read the database's own name from the connection.") + case .incompatibleTool(let message): + return message + } + } +} + +internal enum NativeDumpExitPolicy: Equatable, Sendable { + case zeroExitOnly + case toleratesUnrecognizedSessionSettings + + internal func skippedSettings(exitCode: Int32, stderr: String) -> [String]? { + switch self { + case .zeroExitOnly: + return nil + case .toleratesUnrecognizedSessionSettings: + return PostgresRestoreDiagnostics.skippedSessionSettings(exitCode: exitCode, stderr: stderr) } } } @@ -87,6 +104,8 @@ struct NativeDumpCommand: Equatable { /// takes standard output and writes it out. let isRestore: Bool + internal let exitPolicy: NativeDumpExitPolicy + init( executable: URL, arguments: [String], @@ -95,7 +114,8 @@ struct NativeDumpCommand: Equatable { delivery: NativeDumpDescriptor.OutputDelivery = .toolWritesFile, redirectedFileURL: URL? = nil, temporaryCredentialsFileURL: URL? = nil, - isRestore: Bool = false + isRestore: Bool = false, + exitPolicy: NativeDumpExitPolicy = .zeroExitOnly ) { self.executable = executable self.arguments = arguments @@ -105,6 +125,7 @@ struct NativeDumpCommand: Equatable { self.redirectedFileURL = redirectedFileURL self.temporaryCredentialsFileURL = temporaryCredentialsFileURL self.isRestore = isRestore + self.exitPolicy = exitPolicy } } @@ -124,6 +145,13 @@ enum NativeDumpJob { case statements(NativeDumpStatementJob) } +internal extension NativeDumpJob { + var exitPolicy: NativeDumpExitPolicy { + guard case .process(let command) = self else { return .zeroExitOnly } + return command.exitPolicy + } +} + /// Captured terminal state of a finished/cancelled run. struct NativeDumpRunResult: Equatable { let exitCode: Int32 @@ -246,17 +274,11 @@ final class NativeDumpService { switch descriptor.mechanism { case .commandLineTool(let tool): - let candidates = tool.binaries(for: kind) - guard let resolved = candidates.lazy.compactMap({ name -> (String, String)? in - guard let path = CLIExecutableFinder.findExecutable(name) else { return nil } - return (name, path) - }).first else { - throw NativeDumpError.binaryNotFound( - name: candidates.formatted(.list(type: .or)), - installHint: tool.installHint - ) - } - let (binaryName, resolvedPath) = resolved + let (binaryName, resolvedPath) = try await Self.resolveExecutable( + tool: tool, + kind: kind, + serverVersion: session?.driver?.serverVersion + ) toolName = binaryName let command = try Self.buildCommand( kind: kind, @@ -305,6 +327,7 @@ final class NativeDumpService { let runner = runnerFactory(job) try runner.start() self.runner = runner + let exitPolicy = job.exitPolicy setState(.running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate)) if kind == .backup { @@ -313,7 +336,7 @@ final class NativeDumpService { Task { @MainActor [weak self] in guard let result = await self?.runner?.result else { return } - self?.handleTermination(result: result, database: database, fileURL: fileURL) + self?.handleTermination(result: result, database: database, fileURL: fileURL, exitPolicy: exitPolicy) } } @@ -325,6 +348,62 @@ final class NativeDumpService { // MARK: - Resolution + private static func resolveExecutable( + tool: NativeDumpDescriptor.CommandLineTool, + kind: NativeDumpKind, + serverVersion: String? + ) async throws -> (name: String, path: String) { + let candidates = tool.binaries(for: kind) + let selector = tool.toolForServer + let resolved = await Task.detached { + resolve(candidates: candidates, serverVersion: serverVersion, selector: selector) + }.value + switch resolved { + case .found(let name, let path): + return (name, path) + case .incompatible(let message): + throw NativeDumpError.incompatibleTool(message: message) + case .missing: + throw NativeDumpError.binaryNotFound( + name: candidates.formatted(.list(type: .or)), + installHint: tool.installHint + ) + } + } + + private enum ExecutableResolution: Sendable { + case found(name: String, path: String) + case incompatible(String) + case missing + } + + nonisolated private static func resolve( + candidates: [String], + serverVersion: String?, + selector: (@Sendable (String, String?) -> NativeDumpToolSelection)? + ) -> ExecutableResolution { + guard let selector else { + for name in candidates { + guard let path = CLIExecutableFinder.findExecutable(name) else { continue } + return .found(name: name, path: path) + } + return .missing + } + + var refusal: String? + for name in candidates { + switch selector(name, serverVersion) { + case .found(let path): + return .found(name: name, path: path) + case .incompatible(let message): + refusal = refusal ?? message + case .missing: + continue + } + } + return refusal.map { .incompatible($0) } ?? .missing + } + /// The path a file-backed driver actually opens, read from wherever that driver keeps it. /// SQLite uses `database`; DuckDB and libSQL use a plugin-declared additional field. static func localFilePath(for connection: DatabaseConnection) -> String? { @@ -389,6 +468,9 @@ final class NativeDumpService { var arguments = tool.arguments(for: kind, request: request) var environment = minimalEnvironment() environment.merge(tool.environment(request)) { _, new in new } + if tool.requiresUntranslatedMessages { + environment = untranslatedMessagesEnvironment(environment) + } var credentialsFileURL: URL? if tool.needsCredentialsFile, @@ -408,7 +490,8 @@ final class NativeDumpService { delivery: delivery, redirectedFileURL: delivery == .standardOutput ? request.fileURL : nil, temporaryCredentialsFileURL: credentialsFileURL, - isRestore: kind == .restore + isRestore: kind == .restore, + exitPolicy: tool.exitPolicy(for: kind) ) } @@ -436,10 +519,32 @@ final class NativeDumpService { return "\"\(escaped)\"" } + /// The locale variables a spawned tool reads, named rather than spelled at each use site. The + /// preference-key guard scans this target's sources for a quoted string passed to a `forKey:` + /// label and reports anything outside the `com.TablePro` namespace, so a locale variable + /// written that way reads as a stray `UserDefaults` key. Comments are scanned too. + nonisolated internal enum LocaleEnvironmentKey { + static let everyCategory = "LC_ALL" + static let characterHandling = "LC_CTYPE" + static let messages = "LC_MESSAGES" + } + nonisolated private static let inheritedEnvironmentKeys: [String] = [ - "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL" + "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", LocaleEnvironmentKey.everyCategory ] + nonisolated internal static let untranslatedMessagesLocale = "C" + + nonisolated internal static func untranslatedMessagesEnvironment(_ environment: [String: String]) -> [String: String] { + var result = environment + let everyCategory = result.removeValue(forKey: LocaleEnvironmentKey.everyCategory) + if let everyCategory, result[LocaleEnvironmentKey.characterHandling] == nil { + result[LocaleEnvironmentKey.characterHandling] = everyCategory + } + result[LocaleEnvironmentKey.messages] = untranslatedMessagesLocale + return result + } + nonisolated static func minimalEnvironment() -> [String: String] { let parent = ProcessInfo.processInfo.environment var env: [String: String] = [:] @@ -454,7 +559,8 @@ final class NativeDumpService { private func handleTermination( result: NativeDumpRunResult, database: String, - fileURL: URL + fileURL: URL, + exitPolicy: NativeDumpExitPolicy ) { byteSizeTask?.cancel() byteSizeTask = nil @@ -475,6 +581,23 @@ final class NativeDumpService { return } + if let skipped = exitPolicy.skippedSettings(exitCode: result.exitCode, stderr: result.stderr) { + setState(.finished( + database: database, + fileURL: fileURL, + bytesProcessed: writtenBytes, + skippedSettings: skipped + )) + let settings = skipped.joined(separator: ",") + Self.logger.notice( + """ + \(self.toolName, privacy: .public) finished skipping settings=\(settings, privacy: .public) \ + db=\(database, privacy: .public) + """ + ) + return + } + if kind == .backup { Self.removeDestination(fileURL) } let summary = result.stderr.isEmpty ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) diff --git a/TablePro/Core/Database/NativeDumpToolSelection.swift b/TablePro/Core/Database/NativeDumpToolSelection.swift new file mode 100644 index 0000000000..29d9217775 --- /dev/null +++ b/TablePro/Core/Database/NativeDumpToolSelection.swift @@ -0,0 +1,16 @@ +// +// NativeDumpToolSelection.swift +// TablePro +// + +import Foundation + +/// Which binary a tool-driven dump or restore should run, once the server it talks to is known. +/// +/// `incompatible` is not `missing`: the tool is installed and cannot reach this server, so the +/// message names the version to install rather than the package that provides the command. +enum NativeDumpToolSelection: Equatable, Sendable { + case found(path: String) + case incompatible(String) + case missing +} diff --git a/TablePro/Core/Database/ObjectCommentEditing.swift b/TablePro/Core/Database/ObjectCommentEditing.swift new file mode 100644 index 0000000000..bbe40db341 --- /dev/null +++ b/TablePro/Core/Database/ObjectCommentEditing.swift @@ -0,0 +1,127 @@ +// +// ObjectCommentEditing.swift +// TablePro +// +// Sets or clears the comment on a table, view, materialized view or foreign table through the +// execution gate. +// + +import Combine +import Foundation +import TableProPluginKit + +enum DatabaseObjectCommandError: LocalizedError, Equatable { + case notConnected + case unsupported + case denied(String) + + var errorDescription: String? { + switch self { + case .notConnected: String(localized: "Not connected to database") + case .unsupported: String(localized: "This database cannot run this command on this object") + case let .denied(reason): reason + } + } +} + +@MainActor +enum ObjectCommentEditing { + /// The current comment, read fresh rather than taken from the sidebar's listing, which may + /// predate a change made in another window or another app. + static func currentComment(of target: DatabaseObjectTarget) async throws -> String? { + let name = target.name + let metadata = try await DatabaseManager.shared.withMetadataDriver(scope: target.scope) { driver in + try await driver.fetchTableMetadata(tableName: name) + } + return metadata.comment + } + + static func statement(for comment: String?, on target: DatabaseObjectTarget, driver: DatabaseDriver) -> String? { + driver.objectCommentStatement( + name: target.name, + objectType: target.type.rawValue, + schema: target.schema, + comment: comment + ) + } + + /// Runs on the schema change route, like every other statement the app writes on the user's + /// behalf: on the session driver it would join a transaction a query tab left open and be + /// undone by that tab's rollback. + static func setComment( + _ comment: String?, + on target: DatabaseObjectTarget, + connection: DatabaseConnection, + gate: any ExecutionGate = ExecutionGateProvider.shared + ) async throws { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw DatabaseObjectCommandError.notConnected + } + guard let sql = statement(for: comment, on: target, driver: driver) else { + throw DatabaseObjectCommandError.unsupported + } + try await DatabaseObjectCommandRunner.run( + sql, + on: target, + connection: connection, + kind: .schemaMutation, + operationDescription: String(localized: "Edit Comment"), + gate: gate + ) + AppCommands.shared.objectChanged.send(target.change(.comment)) + } +} + +/// The part every object command shares: authorize, run on a lease no Stop can reach, record it. +@MainActor +enum DatabaseObjectCommandRunner { + static func run( + _ sql: String, + on target: DatabaseObjectTarget, + connection: DatabaseConnection, + kind: OperationKind, + operationDescription: String, + gate: any ExecutionGate + ) async throws { + let decision = await gate.authorize( + OperationRequest( + connectionId: connection.id, + databaseType: connection.type, + sql: sql, + kind: kind, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: operationDescription + ) + ) + guard case .authorized = decision else { + throw DatabaseObjectCommandError.denied( + decision.deniedReason ?? String(localized: "Operation not permitted") + ) + } + + let scope = target.scope + let startedAt = Date() + try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: DatabaseManager.shared.schemaChangeRoute(for: scope), + cancellation: .protectedWrite + ) { driver in + _ = try await driver.execute(query: sql) + } + + await DatabaseManager.shared.historyRecorder.record( + QueryHistoryRecordRequest( + query: sql, + connectionId: connection.id, + databaseName: scope.database, + databaseType: connection.type, + schemaName: scope.schema, + source: .structureDDL, + executionTime: Date().timeIntervalSince(startedAt), + rowCount: -1, + wasSuccessful: true + ) + ) + } +} diff --git a/TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift b/TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift new file mode 100644 index 0000000000..332ec4bc45 --- /dev/null +++ b/TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift @@ -0,0 +1,108 @@ +// +// PostgreSQLDumpToolCompatibility.swift +// TablePro +// + +import Foundation + +enum PostgreSQLDumpToolCompatibility { + struct Candidate: Equatable, Sendable { + let path: String + let version: PostgreSQLServerVersion + } + + /// `pg_dump` learned `-d` in 9.3, and every argument list here names the database with it. + /// Measured: 9.2.23 answers `invalid option -- 'd'`, 9.3.25 runs. + static let oldestUsableTool = PostgreSQLServerVersion(number: 90_300) + + private static let toolsDroppingPreNinePointTwoServers = PostgreSQLServerVersion(number: 150_000) + private static let toolsDroppingPreEightServers = PostgreSQLServerVersion(number: 100_000) + + static func oldestServer(for tool: PostgreSQLServerVersion) -> PostgreSQLServerVersion { + if tool >= toolsDroppingPreNinePointTwoServers { + return PostgreSQLServerVersion(number: 90_200) + } + if tool >= toolsDroppingPreEightServers { + return PostgreSQLServerVersion(number: 80_000) + } + return PostgreSQLServerVersion(number: 70_000) + } + + static func canDump(server: PostgreSQLServerVersion, with tool: PostgreSQLServerVersion) -> Bool { + guard tool >= oldestUsableTool else { return false } + guard server.majorReleaseNumber <= tool.majorReleaseNumber else { return false } + return server >= oldestServer(for: tool) + } + + /// The newest tool release that still reaches this server, or nil when every later release does. + /// `pg_dump` 10 dropped servers before 8.0 and 15 dropped servers before 9.2, so a 7.x server + /// needs 9.6 or earlier and an 8.x or 9.1 server needs 14 or earlier. + static func newestUsableTool(for server: PostgreSQLServerVersion) -> PostgreSQLServerVersion? { + let ceilings = [ + (dropsServersBelow: 80_000, newestTool: 90_600), + (dropsServersBelow: 90_200, newestTool: 140_000) + ] + for ceiling in ceilings where server.number < ceiling.dropsServersBelow { + return PostgreSQLServerVersion(number: ceiling.newestTool) + } + return nil + } + + static func choose( + for server: PostgreSQLServerVersion, + preferred: Candidate?, + others: [Candidate] + ) -> Candidate? { + if let preferred, canDump(server: server, with: preferred.version) { + return preferred + } + return others + .filter { canDump(server: server, with: $0.version) } + .max { $0.version < $1.version } + } + + private static func installHint(newestUsableTool: PostgreSQLServerVersion?) -> String { + guard let newestUsableTool else { return String(localized: "Install it with `brew install libpq`.") } + guard newestUsableTool >= PostgreSQLServerVersion(number: 100_000) else { + return String( + format: String(localized: "Install one from PostgreSQL %@ or earlier."), + newestUsableTool.majorReleaseName + ) + } + return String( + format: String(localized: "Install one with `brew install postgresql@%@`."), + newestUsableTool.majorReleaseName + ) + } + + static func refusal( + for server: PostgreSQLServerVersion, + found: [Candidate], + toolName: String + ) -> String { + let oldest = PostgreSQLServerVersion.release( + max(oldestUsableTool.majorReleaseNumber, server.majorReleaseNumber) + ) + let oldestName = oldest.majorReleaseName + let requirement: String + if let newest = newestUsableTool(for: server) { + requirement = String( + format: String(localized: "PostgreSQL %1$@ needs %2$@ %3$@ to %4$@."), + server.majorReleaseName, toolName, oldestName, newest.majorReleaseName + ) + } else { + requirement = String( + format: String(localized: "PostgreSQL %1$@ needs %2$@ %3$@ or later."), + server.majorReleaseName, toolName, oldestName + ) + } + let install = installHint(newestUsableTool: newestUsableTool(for: server)) + let versions = Array(Set(found.map { $0.version })).sorted() + guard !versions.isEmpty else { return "\(requirement) \(install)" } + let foundList = versions.map(\.fullName).formatted(.list(type: .and)) + let foundSentence = String( + format: String(localized: "Found %1$@ %2$@."), toolName, foundList + ) + return "\(requirement) \(install) \(foundSentence)" + } +} diff --git a/TablePro/Core/Database/PostgreSQLDumpToolLocator.swift b/TablePro/Core/Database/PostgreSQLDumpToolLocator.swift new file mode 100644 index 0000000000..4a2f002b7d --- /dev/null +++ b/TablePro/Core/Database/PostgreSQLDumpToolLocator.swift @@ -0,0 +1,164 @@ +// +// PostgreSQLDumpToolLocator.swift +// TablePro +// + +import Foundation +import os + +enum PostgreSQLDumpToolLocator { + private static let logger = Logger(subsystem: "com.TablePro", category: "PostgreSQLDumpToolLocator") + + static let versionProbeTimeout: TimeInterval = 3 + + static let searchRoots = [ + "/opt/homebrew/opt", + "/usr/local/opt", + "/Applications/Postgres.app/Contents/Versions", + "/Library/PostgreSQL" + ] + + /// What a probed binary reported. A binary whose `--version` cannot be read or parsed is + /// `unknown`: it is used when nothing better is found, because refusing it would hide a tool + /// that worked before any of this existed. + enum ProbedVersion: Equatable, Sendable { + case known(PostgreSQLServerVersion) + case unknown + } + + static func select( + binary: String, + serverVersion: String?, + roots: [String] = searchRoots, + fileManager: FileManager = .default, + pathBinary: (String) -> String? = { CLIExecutableFinder.findExecutable($0) }, + probe: (String) -> ProbedVersion = { probeVersion(of: $0) } + ) -> NativeDumpToolSelection { + let preferredPath = pathBinary(binary) + guard let server = PostgreSQLServerVersion(serverVersion) else { + return preferredPath.map { .found(path: $0) } ?? .missing + } + + var preferred: PostgreSQLDumpToolCompatibility.Candidate? + var unknownVersionPaths: [String] = [] + if let preferredPath { + switch probe(preferredPath) { + case .known(let version): + preferred = .init(path: preferredPath, version: version) + case .unknown: + unknownVersionPaths.append(preferredPath) + } + } + if let preferred, PostgreSQLDumpToolCompatibility.canDump(server: server, with: preferred.version) { + return chosen(path: preferred.path, version: preferred.version, server: server, binary: binary) + } + + let preferredResolved = preferredPath.map { resolvedPath($0) } + var others: [PostgreSQLDumpToolCompatibility.Candidate] = [] + for path in installedPaths(binary: binary, roots: roots, fileManager: fileManager) + where resolvedPath(path) != preferredResolved { + switch probe(path) { + case .known(let version): + others.append(.init(path: path, version: version)) + case .unknown: + unknownVersionPaths.append(path) + } + } + + if let choice = PostgreSQLDumpToolCompatibility.choose(for: server, preferred: nil, others: others) { + return chosen(path: choice.path, version: choice.version, server: server, binary: binary) + } + if let fallback = unknownVersionPaths.first { + logger.warning( + """ + \(binary, privacy: .public) at \(fallback, privacy: .public) reports no readable version; \ + using it for a \(server.majorReleaseName, privacy: .public) server + """ + ) + return .found(path: fallback) + } + + let found = ([preferred].compactMap { $0 }) + others + guard !found.isEmpty else { return .missing } + return .incompatible( + PostgreSQLDumpToolCompatibility.refusal(for: server, found: found, toolName: binary) + ) + } + + static func installedPaths( + binary: String, + roots: [String] = searchRoots, + fileManager: FileManager = .default + ) -> [String] { + var paths: [String] = [] + var seen = Set() + for root in roots { + guard let entries = try? fileManager.contentsOfDirectory(atPath: root) else { continue } + for entry in entries.sorted() where isPostgreSQLInstall(entry, under: root) { + let path = "\(root)/\(entry)/bin/\(binary)" + guard fileManager.isExecutableFile(atPath: path) else { continue } + guard seen.insert(resolvedPath(path)).inserted else { continue } + paths.append(path) + } + } + return paths + } + + static func probeVersion(of path: String, timeout: TimeInterval = versionProbeTimeout) -> ProbedVersion { + guard let output = versionOutput(of: path, timeout: timeout), + let version = PostgreSQLServerVersion(output) else { + return .unknown + } + return .known(version) + } + + private static func chosen( + path: String, + version: PostgreSQLServerVersion, + server: PostgreSQLServerVersion, + binary: String + ) -> NativeDumpToolSelection { + logger.info( + """ + \(binary, privacy: .public) \(version.fullName, privacy: .public) at \(path, privacy: .public) \ + for a PostgreSQL \(server.fullName, privacy: .public) server + """ + ) + return .found(path: path) + } + + private static func isPostgreSQLInstall(_ entry: String, under root: String) -> Bool { + guard root.hasSuffix("/opt") else { return true } + return entry.hasPrefix("postgresql") || entry.hasPrefix("libpq") + } + + private static func resolvedPath(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + + private static func versionOutput(of path: String, timeout: TimeInterval) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = ["--version"] + process.environment = CLIToolEnvironment.augmented() + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in finished.signal() } + do { + try process.run() + } catch { + return nil + } + + if finished.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + logger.warning("\(path, privacy: .public) did not answer --version within \(timeout, privacy: .public)s") + return nil + } + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } +} diff --git a/TablePro/Core/Database/PostgreSQLServerVersion.swift b/TablePro/Core/Database/PostgreSQLServerVersion.swift new file mode 100644 index 0000000000..8d6b40db15 --- /dev/null +++ b/TablePro/Core/Database/PostgreSQLServerVersion.swift @@ -0,0 +1,85 @@ +// +// PostgreSQLServerVersion.swift +// TablePro +// + +import Foundation + +enum PostgreSQLJSONColumnType: String, Sendable { + case jsonb = "JSONB" + case json = "JSON" + case text = "TEXT" +} + +struct PostgreSQLServerVersion: Comparable, Hashable, Sendable { + typealias JSONColumnType = PostgreSQLJSONColumnType + + static let stateColumns = PostgreSQLServerVersion(number: 90_200) + static let jsonType = PostgreSQLServerVersion(number: 90_200) + static let jsonbType = PostgreSQLServerVersion(number: 90_400) + static let backendTypeColumn = PostgreSQLServerVersion(number: 100_000) + + let number: Int + + init(number: Int) { + self.number = number + } + + init?(_ text: String?) { + guard let text, let components = Self.leadingVersionComponents(in: text) else { return nil } + let major = components[0] + let second = components.count > 1 ? components[1] : 0 + let third = components.count > 2 ? components[2] : 0 + self.number = major >= 10 + ? major * 10_000 + second + : major * 10_000 + second * 100 + third + } + + static func release(_ majorReleaseNumber: Int) -> PostgreSQLServerVersion { + PostgreSQLServerVersion(number: majorReleaseNumber * 100) + } + + var majorReleaseNumber: Int { + number / 100 + } + + var majorReleaseName: String { + let major = number / 10_000 + guard major < 10 else { return String(major) } + return "\(major).\((number / 100) % 100)" + } + + var fullName: String { + let major = number / 10_000 + guard major < 10 else { return "\(major).\(number % 10_000)" } + return "\(major).\((number / 100) % 100).\(number % 100)" + } + + /// The richest JSON column type a target holds. + /// + /// Only `.postgresql` is read from the reported version. Redshift reports 8.0.2 and CockroachDB + /// reports 13.0.0, and neither number describes the JSON support that engine actually has. + static func jsonColumnType(for databaseType: DatabaseType, serverVersion: String?) -> JSONColumnType { + guard databaseType == .postgresql, let version = PostgreSQLServerVersion(serverVersion) else { + return .jsonb + } + if version >= .jsonbType { return .jsonb } + return version >= .jsonType ? .json : .text + } + + static func < (lhs: PostgreSQLServerVersion, rhs: PostgreSQLServerVersion) -> Bool { + lhs.number < rhs.number + } + + private static func leadingVersionComponents(in text: String) -> [Int]? { + let tokens = text.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" }) + for token in tokens { + guard let first = token.first, first.isASCII, first.isNumber else { continue } + let numeric = token.prefix { $0.isASCII && ($0.isNumber || $0 == ".") } + let components = numeric.split(separator: ".").compactMap { Int($0) } + guard let major = components.first, major > 0 else { continue } + return components + } + return nil + } +} diff --git a/TablePro/Core/Database/PostgresRestoreDiagnostics.swift b/TablePro/Core/Database/PostgresRestoreDiagnostics.swift new file mode 100644 index 0000000000..9f42dc1418 --- /dev/null +++ b/TablePro/Core/Database/PostgresRestoreDiagnostics.swift @@ -0,0 +1,121 @@ +// +// PostgresRestoreDiagnostics.swift +// TablePro +// + +import Foundation + +internal enum PostgresRestoreDiagnostics { + private static let errorsIgnoredExitCode: Int32 = 1 + private static let errorsIgnoredPrefix = "pg_restore: warning: errors ignored on restore: " + private static let phaseContextLines: Set = [ + "pg_restore: while INITIALIZING:", + "pg_restore: while PROCESSING TOC:" + ] + private static let tocEntryContextPrefix = "pg_restore: from TOC entry " + private static let executeQueryPrefix = "pg_restore: error: could not execute query: " + private static let couldNotSetPrefix = "pg_restore: error: could not set " + private static let setCommandPrefix = "Command was: SET " + private static let unrecognizedParameterPrefix = "ERROR: unrecognized configuration parameter \"" + + internal static func skippedSessionSettings(exitCode: Int32, stderr: String) -> [String]? { + guard exitCode == errorsIgnoredExitCode else { return nil } + let lines = stderr + .split(whereSeparator: \.isNewline) + .map(String.init) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + guard let summary = lines.last, + let reportedErrorCount = errorsIgnoredCount(summary), + let skipped = rejectedSettings(in: lines.dropLast()), + !skipped.isEmpty, + skipped.count == reportedErrorCount + else { return nil } + return uniqued(skipped) + } + + private static func rejectedSettings(in lines: ArraySlice) -> [String]? { + var settings: [String] = [] + var remaining = lines + while let line = remaining.popFirst() { + if isContext(line) { continue } + if let setting = couldNotSetSetting(line) { + settings.append(setting) + continue + } + guard let command = remaining.popFirst(), + let setting = rejectedSetCommandSetting(error: line, command: command) + else { return nil } + settings.append(setting) + } + return settings + } + + private static func isContext(_ line: String) -> Bool { + phaseContextLines.contains(line) || line.hasPrefix(tocEntryContextPrefix) + } + + private static func errorsIgnoredCount(_ line: String) -> Int? { + guard line.hasPrefix(errorsIgnoredPrefix) else { return nil } + return Int(line.dropFirst(errorsIgnoredPrefix.count)) + } + + private static func couldNotSetSetting(_ line: String) -> String? { + guard line.hasPrefix(couldNotSetPrefix) else { return nil } + let remainder = line.dropFirst(couldNotSetPrefix.count) + let quoted = remainder.first == "\"" + let nameStart = quoted ? remainder.index(after: remainder.startIndex) : remainder.startIndex + let separator = quoted ? "\": " : ": " + guard let separatorRange = remainder[nameStart...].range(of: separator) else { return nil } + let setting = String(remainder[nameStart.. String? { + guard error.hasPrefix(executeQueryPrefix), + let setting = unrecognizedParameter(in: String(error.dropFirst(executeQueryPrefix.count))) + else { return nil } + let assignment = setCommandPrefix + setting + " = " + guard command.hasPrefix(assignment), command.hasSuffix(";") else { return nil } + let value = command.dropFirst(assignment.count).dropLast() + guard isSingleValue(value) else { return nil } + return setting + } + + private static func unrecognizedParameter(in serverMessage: String) -> String? { + guard serverMessage.hasPrefix(unrecognizedParameterPrefix), serverMessage.hasSuffix("\"") else { return nil } + let name = String(serverMessage.dropFirst(unrecognizedParameterPrefix.count).dropLast()) + return isSettingName(name) ? name : nil + } + + private static func isSettingName(_ name: String) -> Bool { + !name.isEmpty && name.allSatisfy { character in + character == "_" || character == "." || (character.isASCII && (character.isLetter || character.isNumber)) + } + } + + private static func isSingleValue(_ value: Substring) -> Bool { + guard value.first == "'" else { + return !value.isEmpty && value.allSatisfy { character in + character.isASCII && (character.isLetter || character.isNumber || "_.+-".contains(character)) + } + } + return isQuotedLiteral(value) + } + + private static func isQuotedLiteral(_ value: Substring) -> Bool { + guard value.count >= 2, value.first == "'", value.last == "'" else { return false } + var inner = value.dropFirst().dropLast() + while let character = inner.popFirst() { + guard character == "'" else { continue } + guard inner.popFirst() == "'" else { return false } + } + return true + } + + private static func uniqued(_ settings: [String]) -> [String] { + var seen: Set = [] + return settings.filter { seen.insert($0).inserted } + } +} diff --git a/TablePro/Core/Database/TableDDLComposer.swift b/TablePro/Core/Database/TableDDLComposer.swift index bd3438ee58..74aab77285 100644 --- a/TablePro/Core/Database/TableDDLComposer.swift +++ b/TablePro/Core/Database/TableDDLComposer.swift @@ -7,23 +7,74 @@ import Foundation /// Joins a table's `CREATE TABLE` to the statements that stand outside it. /// -/// A driver answers `fetchTableDDL` with the table alone and `fetchIndexDDL` with the indexes that -/// statement does not declare, because a dump replays them in different phases: the table before -/// its rows, the indexes after. Anything showing one table's whole definition at once, Copy DDL and -/// the MCP schema tools among them, puts the two back together here rather than each spelling out -/// its own separator. +/// A driver answers `fetchTableDDL` with the table alone, `fetchCommentDDL` with its comments and +/// `fetchIndexDDL` with the indexes that statement does not declare, because a dump replays them in +/// different phases: the table and its comments before its rows, the indexes after. Anything showing +/// one table's whole definition at once, Copy DDL and the MCP schema tools among them, puts the +/// pieces back together here rather than each spelling out its own separator. internal enum TableDDLComposer { - internal static func compose(tableDDL: String, indexDDL: [String], preamble: String = "") -> String { - let statements = indexDDL - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .map { $0.hasSuffix(";") ? $0 : "\($0);" } + /// The comments keep the dump's own placement, between the table and its indexes, so the text + /// Show DDL and Copy DDL hand over is the text a restore runs. + internal static func compose( + tableDDL: String, + indexDDL: [String], + commentDDL: [String] = [], + preamble: String = "" + ) -> String { + let comments = terminated(commentDDL) + let indexes = terminated(indexDDL) var composed = preamble.isEmpty ? tableDDL : "\(preamble)\n\(tableDDL)" - guard !statements.isEmpty else { return composed } + guard !comments.isEmpty || !indexes.isEmpty else { return composed } if !composed.hasSuffix(";") { composed += ";" } - return composed + "\n\n" + statements.joined(separator: "\n") + for block in [comments, indexes] where !block.isEmpty { + composed += "\n\n" + block.joined(separator: "\n") + } + return composed + } + + private static func terminated(_ statements: [String]) -> [String] { + statements + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { $0.hasSuffix(";") ? $0 : "\($0);" } + } + + /// One object's whole definition, read on a driver already pinned to its scope. The Structure + /// tab's DDL, Show DDL and Copy DDL all come through here, so the three can never disagree about + /// the same object. `includesDependencies` adds the sequences and enum types the table's columns + /// use, which the Structure tab writes first so its text runs on its own. + internal static func fetchDDL( + for table: String, + using driver: DatabaseDriver, + includesDependencies: Bool + ) async throws -> String { + let preamble = includesDependencies ? try await dependencyPreamble(for: table, using: driver) : "" + let baseDDL = try await driver.fetchTableDDL(table: table) + let indexDDL = (try? await driver.fetchIndexDDL(table: table)) ?? [] + let commentDDL = (try? await driver.fetchCommentDDL(table: table)) ?? [] + return compose( + tableDDL: baseDDL, + indexDDL: indexDDL, + commentDDL: commentDDL, + preamble: preamble + ) + } + + private static func dependencyPreamble(for table: String, using driver: DatabaseDriver) async throws -> String { + let sequences = try await driver.fetchDependentSequences(forTable: table) + let enumTypes = try await driver.fetchDependentTypes(forTable: table) + var preamble = "" + for sequence in sequences { + preamble += sequence.ddl + "\n\n" + } + for enumType in enumTypes { + let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0))'" } + preamble += "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n" + } + return preamble } } diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index 1cfa82b8c0..06be42a315 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -31,6 +31,32 @@ struct DataRefreshRequest: Sendable, Equatable { } } +/// A change to one named object, addressed by name rather than by scope. +/// +/// `DataRefreshRequest` reloads whichever tab each window has selected in the scope, whatever table +/// it shows, and asks the user to discard its edits first. A change that touches one object has no +/// business interrupting a tab on another, and the tabs that do show it are reloaded whether they +/// are in front or not. +struct DatabaseObjectChange: Sendable, Equatable { + enum Kind: Sendable, Equatable { + /// The object's rows were recomputed, as a materialized view refresh does. + case rows + /// The object's comment changed. + case comment + } + + let connectionId: UUID + let scope: DatabaseScope + let name: String + let kind: Kind + + /// Whether a tab's table is this object. Schema is compared as the tab stores it, which is the + /// resolved schema for an engine that has them and nil for one that does not. + func matches(tableName: String?, databaseName: String, schemaName: String?) -> Bool { + tableName == name && databaseName == scope.database && schemaName == scope.schema + } +} + @MainActor final class AppCommands { static let shared = AppCommands() @@ -38,6 +64,7 @@ final class AppCommands { // MARK: - Refresh let refreshData = PassthroughSubject() + let objectChanged = PassthroughSubject() let refreshPrincipals = PassthroughSubject() // MARK: - File / Connection Import-Export diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index dd2590173a..36abb193f3 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -13,7 +13,11 @@ final class AppEvents { // MARK: - Theme & Accessibility - let themeChanged = PassthroughSubject() + /// Carries the palette revision and the effective appearance. A consumer that snapshots + /// colours or fonts needs both: a forced Light/Dark switch never changes the theme, and a + /// theme switch never changes the appearance. Subscribing reaches a view that is alive but + /// detached from every window, which a walk of the window tree would miss. + let themeChanged = PassthroughSubject() let accessibilityTextSizeChanged = PassthroughSubject() diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index 3439c1af4e..3318e824d6 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -282,19 +282,16 @@ public actor MCPAuthPolicy { sql: String, connectionId: UUID, databaseType: DatabaseType, - capabilities: CallerCapabilities = [.mayWrite, .mayRunDestructive, .mayRunMultiStatement] + capabilities: CallerCapabilities = [.mayWrite, .mayRunDestructive, .mayRunMultiStatement], + operationLabel: String? = nil ) async throws { - var effective = capabilities - if !principal.has(.admin) || principal.isAnonymous { - effective.remove(.confirmationPreCleared) - effective.remove(.preCleared) - } try await runExecutionGate( sql: sql, connectionId: connectionId, databaseType: databaseType, - capabilities: effective, - callerLabel: principal.auditLabel + capabilities: Self.effectiveCapabilities(capabilities, for: principal), + callerLabel: principal.auditLabel, + operationLabel: operationLabel ) } @@ -309,7 +306,8 @@ public actor MCPAuthPolicy { connectionId: connectionId, databaseType: databaseType, capabilities: capabilities, - callerLabel: nil + callerLabel: nil, + operationLabel: nil ) } @@ -318,7 +316,8 @@ public actor MCPAuthPolicy { connectionId: UUID, databaseType: DatabaseType, capabilities: CallerCapabilities, - callerLabel: String? + callerLabel: String?, + operationLabel: String? ) async throws { let decision = await ExecutionGateProvider.shared.authorize( OperationRequest( @@ -328,7 +327,7 @@ public actor MCPAuthPolicy { kind: OperationKind.from(QueryClassifier.classifyTier(sql, databaseType: databaseType)), caller: .mcpClient(label: callerLabel), capabilities: capabilities, - operationDescription: String(localized: "MCP query execution") + operationDescription: Self.operationDescription(for: operationLabel) ) ) if case .denied(let reason) = decision { @@ -336,6 +335,31 @@ public actor MCPAuthPolicy { } } + /// `confirmationPreCleared` says a human already confirmed, and over MCP nobody did: the client + /// answered its own prompt. Only an admin-scoped token may substitute that for TablePro's + /// dialog, which is what separates Full Access from Read & Write in enforcement rather than only + /// in the settings UI. Reaching the gate without a principal skips this, so + /// `MCPStatementGateGuardTests` checks that the MCP path never does. + static func effectiveCapabilities( + _ capabilities: CallerCapabilities, + for principal: MCPPrincipal + ) -> CallerCapabilities { + guard !principal.has(.admin) || principal.isAnonymous else { return capabilities } + var effective = capabilities + effective.remove(.confirmationPreCleared) + effective.remove(.preCleared) + return effective + } + + /// The same words the client's own elicitation prompt uses, so a user who sees both reads one + /// vocabulary. The connection is named by the dialog's own subtitle rather than twice here. + static func operationDescription(for operationLabel: String?) -> String { + guard let operationLabel, !operationLabel.isEmpty else { + return String(localized: "MCP query execution") + } + return String(format: String(localized: "Allow %@?"), operationLabel) + } + func logQuery( sql: String, connectionId: UUID, diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift index f465555686..73259b1c29 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift @@ -98,17 +98,13 @@ extension MCPConnectionBridge { /// The table's own statement plus the indexes it does not declare, because a caller asking for /// a table's DDL wants what recreates it, not the half the export replays first. static func composedTableDDL(driver: DatabaseDriver, table: String) async -> String? { - guard let base = try? await driver.fetchTableDDL(table: table) else { return nil } - let indexes = (try? await driver.fetchIndexDDL(table: table)) ?? [] - return TableDDLComposer.compose(tableDDL: base, indexDDL: indexes) + try? await TableDDLComposer.fetchDDL(for: table, using: driver, includesDependencies: false) } func getTableDDL(scope: DatabaseScope, table: String) async throws -> JsonValue { try await ensureConnected(scope.connectionId) let ddl = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - let base = try await driver.fetchTableDDL(table: table) - let indexes = (try? await driver.fetchIndexDDL(table: table)) ?? [] - return TableDDLComposer.compose(tableDDL: base, indexDDL: indexes) + try await TableDDLComposer.fetchDDL(for: table, using: driver, includesDependencies: false) } return .object([ "table": .string(table), diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Server.swift b/TablePro/Core/MCP/MCPConnectionBridge+Server.swift index f3f930b9eb..84c86bc8fb 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Server.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Server.swift @@ -93,7 +93,10 @@ extension MCPConnectionBridge { } return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - guard let provider = ServerDashboardQueryProviderFactory.provider(for: databaseType) else { + guard let provider = ServerDashboardQueryProviderFactory.provider( + for: databaseType, + serverVersion: driver.serverVersion + ) else { throw DatabaseAccessError.dataSourceError( String(localized: "TablePro has no server dashboard for this engine.") ) @@ -102,47 +105,92 @@ extension MCPConnectionBridge { try await driver.execute(query: sql) } var result: [String: JsonValue] = [:] + var failures: [String: String] = [:] if panels.contains("sessions") { - let sessions = (try? await provider.fetchSessions(execute: execute)) ?? [] - result["sessions"] = .array(sessions.map { session in - .object([ - "id": .string(session.id), - "user": .string(session.user), - "database": .string(session.database), - "state": .string(session.state), - "duration_seconds": .int(session.durationSeconds), - "query": .string(session.query), - "can_kill": .bool(session.canKill), - "can_cancel": .bool(session.canCancel) - ]) - }) + do { + let sessions = try await provider.fetchSessions(execute: execute) + result["sessions"] = .array(sessions.map { session in + .object([ + "id": .string(session.id), + "user": .string(session.user), + "database": .string(session.database), + "state": .string(session.state), + "duration_seconds": .int(session.durationSeconds), + "query": .string(session.query), + "can_kill": .bool(session.canKill), + "can_cancel": .bool(session.canCancel) + ]) + }) + } catch { + failures["sessions"] = Self.dashboardPanelFailure(panel: "sessions", error: error) + } } if panels.contains("metrics") { - let metrics = (try? await provider.fetchMetrics(execute: execute)) ?? [] - result["metrics"] = .array(metrics.map { metric in - .object([ - "id": .string(metric.id), - "label": .string(metric.label), - "value": .string(metric.value), - "unit": .string(metric.unit) - ]) - }) + do { + let metrics = try await provider.fetchMetrics(execute: execute) + result["metrics"] = .array(metrics.map { metric in + .object([ + "id": .string(metric.id), + "label": .string(metric.label), + "value": .string(metric.value), + "unit": .string(metric.unit) + ]) + }) + } catch { + failures["metrics"] = Self.dashboardPanelFailure(panel: "metrics", error: error) + } } if panels.contains("slow_queries") { - let slow = (try? await provider.fetchSlowQueries(execute: execute)) ?? [] - result["slow_queries"] = .array(slow.map { entry in - .object([ - "duration": .string(entry.duration), - "query": .string(entry.query), - "user": .string(entry.user), - "database": .string(entry.database) - ]) - }) + do { + let slow = try await provider.fetchSlowQueries(execute: execute) + result["slow_queries"] = .array(slow.map { entry in + .object([ + "duration": .string(entry.duration), + "query": .string(entry.query), + "user": .string(entry.user), + "database": .string(entry.database) + ]) + }) + } catch { + failures["slow_queries"] = Self.dashboardPanelFailure(panel: "slow_queries", error: error) + } } - return .object(result) + return try Self.dashboardPayload(panels: result, failures: failures) } } + /// The paired client is told which panel the server refused, never what the server said. The + /// server's own text can name a role, a database, a column or a statement, and this reply + /// leaves the user's Mac; the full message stays in the log for the user to read. + static func dashboardPanelFailure(panel: String, error: Error) -> String { + logger.warning( + """ + Server dashboard panel \(panel, privacy: .public) failed: \ + \(error.localizedDescription, privacy: .private) + """ + ) + return String( + format: String(localized: "The server did not answer the %@ panel."), + panel.replacingOccurrences(of: "_", with: " ") + ) + } + + static func dashboardPayload( + panels: [String: JsonValue], + failures: [String: String] + ) throws -> JsonValue { + guard !failures.isEmpty else { return .object(panels) } + guard !panels.isEmpty else { + let message = failures.keys.sorted() + .compactMap { failures[$0] } + .joined(separator: " ") + throw DatabaseAccessError.dataSourceError(message) + } + var payload = panels + payload["errors"] = .object(failures.mapValues { .string($0) }) + return .object(payload) + } + func sessionControlStatement( connectionId: UUID, processId: String, @@ -165,27 +213,86 @@ extension MCPConnectionBridge { return sql } + /// Each operation carries the object kinds it applies to, its scope and its options, because a + /// client that only knows the name cannot tell that `VACUUM` on a view is skipped by the server or + /// that `VACUUM` reads a `full` option at all. func maintenanceOperations(connectionId: UUID) async throws -> JsonValue { try await ensureConnected(connectionId) let operations = await MainActor.run { - DatabaseManager.shared.driver(for: connectionId)?.supportedMaintenanceOperations() + DatabaseManager.shared.driver(for: connectionId)?.maintenanceOperations() } + let sorted = (operations ?? []).sorted { $0.name < $1.name } return .object([ - "operations": .array((operations ?? []).sorted().map { .string($0) }), + "operations": .array(sorted.map(Self.encode(maintenance:))), "is_supported": .bool(operations != nil) ]) } + static func encode(maintenance operation: PluginMaintenanceOperation) -> JsonValue { + .object([ + "name": .string(operation.name), + "applies_to": .array(operation.appliesTo.map(\.rawValue).sorted().map { .string($0) }), + "scope": .string(operation.scope.rawValue), + "options": .array(operation.options.map(Self.encode(maintenanceOption:))) + ]) + } + + /// `choices` is left out rather than sent as null for a true/false flag, so the payload stays valid + /// against the declared output schema. + static func encode(maintenanceOption option: PluginMaintenanceOption) -> JsonValue { + var payload: [String: JsonValue] = [ + "key": .string(option.key), + "label": .string(option.label), + "default": .string(option.defaultValue) + ] + if let choices = option.choices { + payload["choices"] = .array(choices.map { .string($0) }) + } + return .object(payload) + } + + /// Refuses an operation the object's kind rules out, rather than handing back a statement the + /// server will skip with a warning and a success tag. The kind comes from the same table listing + /// `list_tables` reads. func maintenanceStatements( - connectionId: UUID, + scope: DatabaseScope, operation: String, table: String?, options: [String: String] ) async throws -> [String] { - try await ensureConnected(connectionId) + try await ensureConnected(scope.connectionId) + let descriptor = try await resolveMaintenanceOperation( + connectionId: scope.connectionId, + operation: operation + ) + if let table { + guard descriptor.scope.admitsObject else { + throw DatabaseAccessError.invalidArgument( + String( + format: String(localized: "%@ acts on the whole database. Omit 'table'."), + descriptor.name + ) + ) + } + try await requireMaintenanceKind(descriptor: descriptor, scope: scope, table: table) + } else { + guard descriptor.scope.admitsDatabase else { + throw DatabaseAccessError.invalidArgument( + String( + format: String(localized: "%@ needs a table. Pass 'table'."), + descriptor.name + ) + ) + } + } + let statements = await MainActor.run { - DatabaseManager.shared.driver(for: connectionId)? - .maintenanceStatements(operation: operation, table: table, options: options) + DatabaseManager.shared.driver(for: scope.connectionId)?.maintenanceStatements( + operation: operation, + table: table, + schema: scope.schema, + options: options + ) } guard let statements, !statements.isEmpty else { throw DatabaseAccessError.invalidArgument( @@ -195,6 +302,46 @@ extension MCPConnectionBridge { return statements } + private func resolveMaintenanceOperation( + connectionId: UUID, + operation: String + ) async throws -> PluginMaintenanceOperation { + let operations = await MainActor.run { + DatabaseManager.shared.driver(for: connectionId)?.maintenanceOperations() + } + guard let match = operations?.first(where: { $0.name == operation }) else { + throw DatabaseAccessError.invalidArgument( + String(localized: "That maintenance operation is not available on this connection.") + ) + } + return match + } + + private func requireMaintenanceKind( + descriptor: PluginMaintenanceOperation, + scope: DatabaseScope, + table: String + ) async throws { + let tables = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.fetchTables(schema: scope.schema) + } + guard let found = tables.first(where: { $0.name == table }) else { + throw DatabaseAccessError.invalidArgument( + String(format: String(localized: "No object named %@ in this schema."), table) + ) + } + let kind = TableOperationEligibility.pluginKind(found.type) + guard descriptor.applies(to: kind) else { + throw DatabaseAccessError.invalidArgument( + String( + format: String(localized: "%1$@ does not apply to a %2$@."), + descriptor.name, + found.type.rawValue + ) + ) + } + } + func sessionContexts(connectionId: UUID) async throws -> JsonValue { try await ensureConnected(connectionId) let scope = await MainActor.run { DatabaseManager.shared.browseScope(for: connectionId) } diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift b/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift index d262af9e4a..7e74bd1256 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift @@ -52,10 +52,12 @@ enum MCPStatementGate { capabilities.formUnion(consent.capabilities) try await services.authPolicy.checkSafeModeDialog( + principal: context.principal, sql: sql, connectionId: meta.connectionId, databaseType: meta.databaseType, - capabilities: capabilities + capabilities: capabilities, + operationLabel: operationLabel ) return classification @@ -114,12 +116,15 @@ enum MCPStatementGate { ) } + /// The only text the client shows beside its Approve control, so it is the whole of what the + /// user reads before saying yes. It keeps its line breaks: a statement folded onto one line is + /// unreadable at the length that matters, and the cut used to land before the `WHERE` clause. + /// The cap is sized for a form field in someone else's interface, not for a view we draw. + static let previewCharacterLimit = 2_000 + static func preview(of sql: String) -> String { - let condensed = sql - .replacingOccurrences(of: "\n", with: " ") - .replacingOccurrences(of: "\t", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard (condensed as NSString).length > 400 else { return condensed } - return (condensed as NSString).substring(to: 400) + "…" + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard (trimmed as NSString).length > previewCharacterLimit else { return trimmed } + return (trimmed as NSString).substring(to: previewCharacterLimit) + "…" } } diff --git a/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift b/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift index 9cac418f14..89fd730b2d 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift @@ -23,8 +23,41 @@ public struct ListMaintenanceOperationsTool: MCPToolImplementation { public static let outputSchema: JsonValue? = MCPToolSchema.object( properties: [ "operations": MCPToolSchema.array( - String(localized: "Operation names, sorted"), - of: MCPToolSchema.stringItem + String(localized: "Operations, sorted by name"), + of: MCPToolSchema.object( + properties: [ + "name": MCPToolSchema.string(String(localized: "Operation name to pass to run_maintenance")), + "applies_to": MCPToolSchema.array( + String( + localized: """ + Object kinds this operation may name, as list_tables reports them. \ + Empty when it acts on the whole database only. + """ + ), + of: MCPToolSchema.stringItem + ), + "scope": MCPToolSchema.string( + String(localized: "Whether the statement names an object, the database, or either"), + enumValues: ["object", "database", "objectOrDatabase"] + ), + "options": MCPToolSchema.array( + String(localized: "Options this operation reads, by key"), + of: MCPToolSchema.object( + properties: [ + "key": MCPToolSchema.string(String(localized: "Key to send in 'options'")), + "label": MCPToolSchema.string(String(localized: "What the option does")), + "default": MCPToolSchema.string(String(localized: "Value used when omitted")), + "choices": MCPToolSchema.array( + String(localized: "Accepted values, or null for a true/false flag"), + of: MCPToolSchema.stringItem + ) + ], + required: ["key", "label", "default"] + ) + ) + ], + required: ["name", "applies_to", "scope", "options"] + ) ), "is_supported": MCPToolSchema.boolean(String(localized: "Whether this engine has maintenance at all")) ], @@ -121,10 +154,17 @@ public struct RunMaintenanceTool: MCPToolImplementation { } let meta = try await ToolConnectionMetadata.resolve(connectionId: connectionId) + /// Resolved before the statements, because the schema it carries is what qualifies the target + /// and its kind is what says whether the operation applies at all. + let scope = try await MCPScopeArguments.resolve( + arguments, + connectionId: connectionId, + services: services + ) let statements: [String] do { statements = try await services.connectionBridge.maintenanceStatements( - connectionId: connectionId, + scope: scope, operation: operation, table: table, options: options @@ -135,11 +175,6 @@ public struct RunMaintenanceTool: MCPToolImplementation { let settings = await services.settingsProvider() let timeoutSeconds = try MCPLimitResolver.resolveTimeoutSeconds(arguments, settings: settings) - let scope = try await MCPScopeArguments.resolve( - arguments, - connectionId: connectionId, - services: services - ) var results: [JsonValue] = [] for statement in statements { diff --git a/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift b/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift index ca3d0701a4..3968c0e6cb 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift @@ -72,6 +72,15 @@ public struct ServerDashboardTool: MCPToolImplementation { ], required: ["duration", "query", "user", "database"] ) + ), + "errors": MCPToolSchema.object( + properties: [ + "sessions": MCPToolSchema.string(String(localized: "Why the sessions panel could not be read")), + "metrics": MCPToolSchema.string(String(localized: "Why the metrics panel could not be read")), + "slow_queries": MCPToolSchema.string( + String(localized: "Why the slow queries panel could not be read") + ) + ] ) ] ) diff --git a/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/TablePro/Core/Menu/DatabaseMenuBuilder.swift index 29927b5d8b..c0c05d6930 100644 --- a/TablePro/Core/Menu/DatabaseMenuBuilder.swift +++ b/TablePro/Core/Menu/DatabaseMenuBuilder.swift @@ -67,6 +67,24 @@ enum DatabaseMenuBuilder { String(localized: "Edit View Definition…"), action: #selector(MainSplitViewController.editViewDefinition(_:)) ), + /// The sidebar's own per-object commands, mirrored so each is reachable from the menu + /// bar and the keyboard, and spelled exactly as the sidebar spells them. + MenuItemFactory.item( + String(localized: "Show DDL"), + action: #selector(MainSplitViewController.showObjectDDL(_:)) + ), + MenuItemFactory.item( + String(localized: "Copy DDL"), + action: #selector(MainSplitViewController.copyObjectDDL(_:)) + ), + MenuItemFactory.item( + String(localized: "Refresh Materialized View…"), + action: #selector(MainSplitViewController.refreshMaterializedView(_:)) + ), + MenuItemFactory.item( + String(localized: "Edit Comment…"), + action: #selector(MainSplitViewController.editObjectComment(_:)) + ), schemaSubmenu(), sessionContextSubmenu(), safeModeSubmenu(), diff --git a/TablePro/Core/Menu/MaintenanceMenuDelegate.swift b/TablePro/Core/Menu/MaintenanceMenuDelegate.swift index 8ac13ef0aa..fa432b38eb 100644 --- a/TablePro/Core/Menu/MaintenanceMenuDelegate.swift +++ b/TablePro/Core/Menu/MaintenanceMenuDelegate.swift @@ -4,8 +4,9 @@ // import AppKit +import TableProPluginKit -/// Which maintenance operations exist depends on the driver and on what is selected, +/// Which maintenance operations exist depends on the driver and on the kind of the selected object, /// so the submenu is filled when it opens rather than at build time. `menuNeedsUpdate` /// is AppKit's hook for exactly that, and the responder chain resolves the window the /// same way it will resolve the item the user picks. @@ -24,7 +25,7 @@ final class MaintenanceMenuDelegate: NSObject, NSMenuDelegate { return } for operation in operations { - let item = NSMenuItem(title: operation, action: Self.action, keyEquivalent: "") + let item = NSMenuItem(title: operation.name, action: Self.action, keyEquivalent: "") item.target = nil item.representedObject = operation menu.addItem(item) diff --git a/TablePro/Core/Menu/ViewMenuBuilder.swift b/TablePro/Core/Menu/ViewMenuBuilder.swift index b6545cdf8c..16a2d8d11a 100644 --- a/TablePro/Core/Menu/ViewMenuBuilder.swift +++ b/TablePro/Core/Menu/ViewMenuBuilder.swift @@ -71,6 +71,10 @@ enum ViewMenuBuilder { shortcut: .toggleFilters, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Highlight Rules…"), + action: #selector(MainSplitViewController.showHighlightRules(_:)) + ), MenuItemFactory.item( String(localized: "Show Query History"), action: #selector(MainSplitViewController.toggleQueryHistory(_:)), diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift index 54ff2d98e7..ede0a85817 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -298,6 +298,7 @@ internal struct ObjectCopyPlanner { /// different tie-break on every launch: the approved script, the progress order and the /// outcome list were all shuffled differently for the same copy. var drafts: [ObjectCopyTableDraft] = [] + let targetServerVersion = manager.driver(for: request.target.connectionId)?.serverVersion for selection in Self.orderedByDependency( scope.objects.filter { reads[$0] != nil }, reads: reads, effectiveSchema: sourceNamespace ) { @@ -325,6 +326,7 @@ internal struct ObjectCopyPlanner { existsInTarget: existsInTarget, sourceSchema: sourceEndpoint.schema ?? read.table.schema, targetSchema: targetEndpoint.schema, + targetServerVersion: targetServerVersion, request: request )) } diff --git a/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift b/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift index d1685d4af6..d6c40b7609 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift @@ -49,6 +49,7 @@ internal struct ObjectCopyTableDraft { existsInTarget: Bool, sourceSchema: String?, targetSchema: String?, + targetServerVersion: String?, request: ObjectCopyRequest ) { self.selection = selection @@ -70,7 +71,10 @@ internal struct ObjectCopyTableDraft { self.targetTable = (writesStructure ? nil : targetSnapshot?.name) ?? snapshot.name let translation = CrossEngineStructureTranslator.translate( - snapshot, from: request.source.databaseType, to: request.target.databaseType + snapshot, + from: request.source.databaseType, + to: request.target.databaseType, + targetServerVersion: targetServerVersion ) self.targetStructure = translation.snapshot self.isCrossEngine = translation.translated diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index b470c13d3d..f27e7d21c0 100644 --- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -213,6 +213,11 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable return try await pluginDriver.fetchIndexDDL(table: table, schema: exportSchema(for: databaseName)) } + func fetchCommentDDL(table: String, databaseName: String) async throws -> [String] { + guard let pluginDriver else { return [] } + return try await pluginDriver.fetchCommentDDL(table: table, schema: exportSchema(for: databaseName)) + } + // MARK: - Object DDL /// A driver addresses a routine, trigger or type through the info object it handed out, which diff --git a/TablePro/Core/Plugins/ImportTypeMapper.swift b/TablePro/Core/Plugins/ImportTypeMapper.swift index 14e5f3455a..82d260e325 100644 --- a/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -7,11 +7,20 @@ import Foundation import TableProPluginKit enum ImportTypeMapper { - static func sqlType(for type: PluginImportFieldType, databaseType: DatabaseType) -> String { + static func sqlType( + for type: PluginImportFieldType, + databaseType: DatabaseType, + serverVersion: String? = nil + ) -> String { switch databaseType { case .postgresql, .redshift, .cockroachdb: - return postgresType(type) - case .mysql, .mariadb, .tidb: + return postgresType( + type, + jsonColumnType: PostgreSQLServerVersion.jsonColumnType( + for: databaseType, serverVersion: serverVersion + ) + ) + case .mysql, .mariadb, .tidb, .oceanbase: return mysqlType(type) case .sqlite: return sqliteType(type) @@ -22,12 +31,12 @@ enum ImportTypeMapper { } } - private static func postgresType(_ type: PluginImportFieldType) -> String { + private static func postgresType(_ type: PluginImportFieldType, jsonColumnType: PostgreSQLJSONColumnType) -> String { switch type { case .integer: return "BIGINT" case .real: return "DOUBLE PRECISION" case .boolean: return "BOOLEAN" - case .json: return "JSONB" + case .json: return jsonColumnType.rawValue case .text: return "TEXT" @unknown default: return "TEXT" } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index e7372081e2..743b968ddc 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -23,6 +23,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor state.withLock { $0.status } } + var hasLostConnection: Bool { + pluginDriver.hasLostConnection + } + var serverVersion: String? { pluginDriver.serverVersion } var parameterStyle: ParameterStyle { pluginDriver.parameterStyle } @@ -389,6 +393,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor var supportsTransactionalDDL: Bool { pluginDriver.supportsTransactionalDDL } + var unsupportedStructureColumnFields: Set { pluginDriver.unsupportedStructureColumnFields } + + var unsupportedIndexTypes: Set { pluginDriver.unsupportedIndexTypes } + func fetchApproximateRowCount(table: String) async throws -> Int? { try await pluginDriver.fetchApproximateRowCount(table: table, schema: pluginDriver.currentSchema) } @@ -424,6 +432,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.fetchIndexDDL(table: table, schema: pluginDriver.currentSchema) } + func fetchCommentDDL(table: String) async throws -> [String] { + try await pluginDriver.fetchCommentDDL(table: table, schema: pluginDriver.currentSchema) + } + func fetchDependentTypes(forTable table: String) async throws -> [(name: String, labels: [String])] { try await pluginDriver.fetchDependentTypes(table: table, schema: pluginDriver.currentSchema) } @@ -758,12 +770,41 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor // MARK: - Maintenance Operations - func supportedMaintenanceOperations() -> [String]? { - pluginDriver.supportedMaintenanceOperations() + func maintenanceOperations() -> [PluginMaintenanceOperation]? { + pluginDriver.maintenanceOperations() + } + + /// The session's own schema stands in only when the caller has none, so a command that does carry + /// the object's schema qualifies with that one rather than with wherever the session points. + func maintenanceStatements( + operation: String, + table: String?, + schema: String?, + options: [String: String] + ) -> [String]? { + pluginDriver.maintenanceStatements( + operation: operation, + table: table, + schema: schema ?? pluginDriver.currentSchema, + options: options + ) + } + + // MARK: - Object Comments and Materialized Views + + func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { + pluginDriver.objectCommentStatement(name: name, objectType: objectType, schema: schema, comment: comment) + } + + func refreshMaterializedViewStatement(name: String, schema: String?, concurrently: Bool) -> String? { + pluginDriver.refreshMaterializedViewStatement(name: name, schema: schema, concurrently: concurrently) } - func maintenanceStatements(operation: String, table: String?, options: [String: String]) -> [String]? { - pluginDriver.maintenanceStatements(operation: operation, table: table, schema: pluginDriver.currentSchema, options: options) + func concurrentRefreshAvailability( + materializedView: String, + schema: String? + ) async throws -> PluginConcurrentRefreshAvailability? { + try await pluginDriver.concurrentRefreshAvailability(materializedView: materializedView, schema: schema) } // MARK: - All Tables Metadata SQL diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index fbb91a7268..8f98d68641 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -555,6 +555,13 @@ extension PluginManager { .structureEditing.foreignKeyEdit ?? .unsupported } + /// The fallback is `.tablesOnly`, never a curated engine's matrix: an engine nobody has measured + /// must not be offered an edit on anything but a table. + func structureEditMatrix(for databaseType: DatabaseType) -> StructureObjectEditMatrix { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .structureEditing.structureEdits ?? .tablesOnly + } + func supportsDropDatabase(for databaseType: DatabaseType) -> Bool { PluginMetadataRegistry.shared.snapshot(for: databaseType)? .capabilities.supportsDropDatabase ?? false diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 2742308b23..6c37c2efe6 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -14,6 +14,24 @@ import TableProPluginKit @MainActor @Observable final class PluginManager { static let shared = PluginManager(userDefaults: AppStorageEnvironment.shared.defaults) + /// Raised to 29 for `maintenanceOperations` on `PluginDatabaseDriver`, plus the + /// `PluginMaintenanceOperation`, `PluginMaintenanceOption`, `PluginMaintenanceScope` and + /// `PluginObjectKind` it answers with. Together they say which object kinds a maintenance + /// operation may name, whether its statement names an object at all, and which options it reads, + /// none of which the older list of bare names could. + /// + /// Raised to 28 before that for `fetchCommentDDL` on `PluginDatabaseDriver` and `PluginExportDataSource`, + /// which is what lets a dump reattach a relation's own comment and its column comments instead + /// of leaving whether they appear at all to each driver's `fetchTableDDL`. + /// + /// Raised to 27 before that for `hasLostConnection`, `unsupportedStructureColumnFields`, + /// `unsupportedIndexTypes` and `schemaOperationRefusal` on `PluginDatabaseDriver`, plus the + /// `PluginSchemaOperation` the last one answers about. + /// + /// Raised to 26 before that for `objectCommentStatement`, `refreshMaterializedViewStatement` and + /// `concurrentRefreshAvailability` on `PluginDatabaseDriver`, plus the + /// `PluginConcurrentRefreshAvailability` the last one answers with. + /// /// Raised to 23 for `releasableResourceCommandTitle` and `releaseIdleResource` on /// `PluginDatabaseDriver`, plus the `PluginResourceRelease` they answer with. Together they let /// a driver hand back a resource its session is holding without ending the session. DuckDB is @@ -37,7 +55,7 @@ final class PluginManager { /// rebuilt CassandraDriver for the v20 requirements it implements none of. Left at 20, such a /// plugin passes `validateBundleVersions` in a shipped v20 app and then fails /// `Bundle.loadAndReturnError`; at 21 that app refuses it and says to update. - nonisolated static let currentPluginKitVersion = 25 + nonisolated static let currentPluginKitVersion = 29 /// Still 19, so every plugin already published for the previous release keeps loading. nonisolated static let minimumCompatiblePluginKitVersion = 19 diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index 2373cc1bbd..e78f817ca7 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -11,7 +11,7 @@ import TableProPluginKit /// The primary type ids here are overwritten by `buildMetadataSnapshot` the moment the plugin /// registers, so these are the pre-load answer for those. For a variant id they are the whole /// answer: `registerVariant` keeps the curated entry and ignores the plugin's own statics, which -/// is the only reason MariaDB, TiDB, Databend, Redshift, CockroachDB and PGlite can differ from +/// is the only reason MariaDB, TiDB, Databend, OceanBase, Redshift, CockroachDB and PGlite can differ from /// the plugin that drives them. extension PluginMetadataRegistry { // swiftlint:disable:next function_body_length @@ -223,6 +223,16 @@ extension PluginMetadataRegistry { section: .advanced ) + let mysqlEncodingField = ConnectionField( + id: "mysqlConnectionEncoding", + label: String(localized: "Encoding"), + fieldType: .dropdown(options: [ + .init(value: "", label: "UTF-8"), + .init(value: "utf8ViaLatin1", label: String(localized: "UTF-8 via Latin 1")) + ]), + section: .advanced + ) + let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ ("MySQL", PluginMetadataSnapshot( displayName: "MySQL", iconName: "mysql-icon", defaultPort: 3_306, @@ -282,7 +292,7 @@ extension PluginMetadataRegistry { columnTypesByCategory: mysqlColumnTypes ), connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: awsIAMFields + [mysqlIdleReleaseField], + additionalConnectionFields: awsIAMFields + [mysqlIdleReleaseField, mysqlEncodingField], category: .relational, tagline: String(localized: "Most popular open-source SQL database"), defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" @@ -346,7 +356,7 @@ extension PluginMetadataRegistry { columnTypesByCategory: mysqlColumnTypes ), connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: awsIAMFields + [mysqlIdleReleaseField], + additionalConnectionFields: awsIAMFields + [mysqlIdleReleaseField, mysqlEncodingField], category: .relational, tagline: String(localized: "Open-source fork of MySQL"), defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" @@ -362,7 +372,9 @@ extension PluginMetadataRegistry { brandColorHex: "#336791", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, - structureEditing: SchemaEditingSupport(columnReorder: .rebuild, foreignKeyEdit: .alter), + structureEditing: SchemaEditingSupport( + columnReorder: .rebuild, foreignKeyEdit: .alter, structureEdits: .postgreSQL + ), capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, supportsImport: true, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index ac98728d42..f8fc0cfa6e 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -12,6 +12,10 @@ extension PluginMetadataRegistry { ExplainVariant(id: "explain-analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN ANALYZE", format: .plainText), ] + static let oceanbaseExplainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), + ] + static let databendRowMatchExcludedTypePrefixes = [ "ARRAY", "MAP", "TUPLE", "VARIANT", "JSON", "BITMAP", "BINARY", "GEOMETRY", "GEOGRAPHY", "VECTOR", ] @@ -30,7 +34,7 @@ extension PluginMetadataRegistry { "Spatial": ["GEOMETRY", "GEOGRAPHY"], ] - static func tidbColumnTypes(from mysqlColumnTypes: [String: [String]]) -> [String: [String]] { + static func mysqlColumnTypesWithoutSpatial(from mysqlColumnTypes: [String: [String]]) -> [String: [String]] { mysqlColumnTypes.filter { $0.key != "Spatial" } } @@ -40,7 +44,18 @@ extension PluginMetadataRegistry { idleReleaseField: ConnectionField ) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { [ - ("TiDB", PluginMetadataSnapshot( + tidbVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), + databendVariant(dialect: dialect, idleReleaseField: idleReleaseField), + oceanbaseVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), + ] + } + + private static func tidbVariant( + dialect: SQLDialectDescriptor, + mysqlColumnTypes: [String: [String]], + idleReleaseField: ConnectionField + ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { + ("TiDB", PluginMetadataSnapshot( displayName: "TiDB", iconName: "tidb-icon", defaultPort: 4_000, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "tidb", parameterStyle: .questionMark, @@ -94,15 +109,21 @@ extension PluginMetadataRegistry { editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: dialect, statementCompletions: [], - columnTypesByCategory: tidbColumnTypes(from: mysqlColumnTypes) + columnTypesByCategory: mysqlColumnTypesWithoutSpatial(from: mysqlColumnTypes) ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [idleReleaseField], category: .relational, tagline: String(localized: "Distributed SQL, MySQL-compatible") - ) - )), - ("Databend", PluginMetadataSnapshot( + ) + )) + } + + private static func databendVariant( + dialect: SQLDialectDescriptor, + idleReleaseField: ConnectionField + ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { + ("Databend", PluginMetadataSnapshot( displayName: "Databend", iconName: "databend-icon", defaultPort: 3_307, requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "", parameterStyle: .questionMark, @@ -162,8 +183,76 @@ extension PluginMetadataRegistry { additionalConnectionFields: [idleReleaseField], category: .analytical, tagline: String(localized: "Cloud data warehouse, built in Rust") - ) + ) + )) + } + + private static func oceanbaseVariant( + dialect: SQLDialectDescriptor, + mysqlColumnTypes: [String: [String]], + idleReleaseField: ConnectionField + ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { + ("OceanBase", PluginMetadataSnapshot( + displayName: "OceanBase", iconName: "oceanbase-icon", defaultPort: 2_881, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "oceanbase", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: oceanbaseExplainVariants, pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["oceanbase"], postConnectActions: [.selectDatabaseFromLastSession], + brandColorHex: "#006AFF", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + structureEditing: SchemaEditingSupport(columnReorder: .alter, foreignKeyEdit: .alter), + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: false, + supportsCheckConstraints: true, + supportsCheckConstraintEditing: false, + supportsGeneratedColumns: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred, + supportsPrincipalConnectionLimit: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["information_schema", "mysql", "oceanbase"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, + .onUpdate, .autoIncrement, .comment, .charset, .collation + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: dialect, + statementCompletions: [], + columnTypesByCategory: mysqlColumnTypesWithoutSpatial(from: mysqlColumnTypes) + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [idleReleaseField], + category: .relational, + tagline: String(localized: "Distributed HTAP, MySQL-compatible") + ) )) - ] } } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index d8f4b855e9..f5b3ce0476 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1141,6 +1141,7 @@ extension PluginMetadataRegistry { + duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() + + weaviatePluginDefaults() } // swiftlint:enable function_body_length } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index c3045a6b2e..bc2a81bef6 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -21,7 +21,7 @@ extension PluginMetadataRegistry { /// /// Two facts qualify: case-insensitive matching, which is why Redshift is spelled /// `postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction)`, and the column type list, - /// which TiDB narrows (no spatial types) and Databend replaces with its own. Each has its own + /// which TiDB and OceanBase narrow (no spatial types) and Databend replaces with its own. Each has its own /// named adoption here rather than a value comparison: `SQLDialectDescriptor` is not /// `Equatable`, and a whole-descriptor diff would report "differs" for Redshift and hand it /// the stub back. diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+WeaviateDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+WeaviateDefaults.swift new file mode 100644 index 0000000000..4ca4b1c04c --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+WeaviateDefaults.swift @@ -0,0 +1,156 @@ +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + func weaviatePluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("Weaviate", PluginMetadataSnapshot( + displayName: "Weaviate", iconName: "weaviate-icon", defaultPort: 8_080, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: [], postConnectActions: [], + brandColorHex: "#01B0D3", + queryLanguageName: "GraphQL", editorLanguage: .javascript, + connectionMode: .network, supportsDatabaseSwitching: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false, + supportsCloudflareTunnel: false, + supportsPrincipalConnectionLimit: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "default", + tableEntityName: "Collections", + containerEntityName: "Cluster", + defaultPrimaryKeyColumn: "uuid", + immutableColumns: ["uuid", "vector"], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: nil, + statementCompletions: weaviateCompletions, + columnTypesByCategory: weaviateColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: weaviateConnectionFields(), + category: .document, + tagline: String(localized: "Open-source vector database"), + hidesBuiltInPassword: true, + hidesBuiltInDatabase: true + ) + )) + ] + } +} + +private let weaviateCompletions: [CompletionEntry] = [ + CompletionEntry( + label: "Get", + insertText: """ + { + Get { + Article(limit: 10) { + title + _additional { id distance } + } + } + } + """ + ), + CompletionEntry( + label: "Near text", + insertText: """ + { + Get { + Article( + nearText: { concepts: ["search term"] } + limit: 10 + ) { + title + _additional { id distance } + } + } + } + """ + ), + CompletionEntry( + label: "Hybrid", + insertText: """ + { + Get { + Article( + hybrid: { query: "search term", alpha: 0.5 } + limit: 10 + ) { + title + _additional { id score } + } + } + } + """ + ), + CompletionEntry(label: "GET /v1/schema", insertText: "GET /v1/schema"), + CompletionEntry(label: "GET /v1/meta", insertText: "GET /v1/meta"), + CompletionEntry(label: "GET /v1/objects", insertText: "GET /v1/objects?class=Article&limit=10") +] + +private let weaviateColumnTypes: [String: [String]] = [ + "Text": ["text", "text[]", "string", "string[]", "uuid", "uuid[]"], + "Numeric": ["int", "int[]", "number", "number[]"], + "Boolean": ["boolean", "boolean[]"], + "Date": ["date", "date[]"], + "Structured": ["object", "object[]", "geoCoordinates", "phoneNumber"], + "Vector": ["vector"] +] + +func weaviateConnectionFields() -> [ConnectionField] { + [ + ConnectionField( + id: "wvAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "none", + fieldType: .dropdown(options: [ + .init(value: "none", label: "None"), + .init(value: "apiKey", label: "API Key") + ]), + section: .authentication + ), + ConnectionField( + id: "wvApiKey", + label: String(localized: "API Key"), + placeholder: "Weaviate API key", + fieldType: .secure, + section: .authentication, + hidesPassword: true + ).withHidesUsername(true), + ConnectionField( + id: "wvSkipTLSVerify", + label: String(localized: "Skip TLS Verification"), + defaultValue: "false", + fieldType: .toggle, + section: .advanced + ) + ] +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index d6c246abaa..c26e9b5a78 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -383,6 +383,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["MariaDB"] = "MySQL" reverseTypeIndex["TiDB"] = "MySQL" reverseTypeIndex["Databend"] = "MySQL" + reverseTypeIndex["OceanBase"] = "MySQL" reverseTypeIndex["Redshift"] = "PostgreSQL" reverseTypeIndex["CockroachDB"] = "PostgreSQL" reverseTypeIndex["PGlite"] = "PostgreSQL" @@ -680,13 +681,13 @@ final class PluginMetadataRegistry: @unchecked Sendable { /// Keyed by `databaseTypeId`. Stale plugins from the registry inherit these on registration. static func fallbackCategory(forTypeId typeId: String) -> DatabaseCategory { switch typeId { - case "MySQL", "MariaDB", "PostgreSQL", "SQLite", "Oracle", "MSSQL": + case "MySQL", "MariaDB", "PostgreSQL", "SQLite", "Oracle", "MSSQL", "OceanBase": return .relational case "Redshift", "ClickHouse", "DuckDB", "BigQuery": return .analytical case "Spanner": return .relational - case "MongoDB", "Elasticsearch", "SurrealDB", "Typesense": + case "MongoDB", "Elasticsearch", "SurrealDB", "Typesense", "Weaviate": return .document case "Redis": return .keyValue @@ -707,6 +708,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { switch typeId { case "MySQL": return String(localized: "Most popular open-source SQL database") case "MariaDB": return String(localized: "Open-source fork of MySQL") + case "OceanBase": return String(localized: "Distributed HTAP, MySQL-compatible") case "PostgreSQL": return String(localized: "Advanced object-relational SQL") case "Redshift": return String(localized: "Amazon's columnar warehouse on Postgres") case "SQLite": return String(localized: "Embedded zero-config SQL database") @@ -728,6 +730,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { case "SurrealDB": return String(localized: "Multi-model database with SurrealQL") case "Kafka": return String(localized: "Event streaming platform") case "Typesense": return String(localized: "Typo-tolerant open-source search engine") + case "Weaviate": return String(localized: "Open-source vector database") default: return "" } } diff --git a/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift b/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift index 8e79933783..893fff192a 100644 --- a/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift +++ b/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift @@ -36,6 +36,18 @@ enum CreateTableStatementComposer { } var issues = plan.issues + let columnRefusals = definition.columns.compactMap { driver.schemaOperationRefusal(.addColumn($0)) } + issues += columnRefusals.map { SchemaDraftIssue(tab: .columns, row: nil, message: $0) } + var refusedIndexRows: Set = [] + for (row, index) in plan.indexes.enumerated() { + guard let reason = driver.schemaOperationRefusal(.addIndex(index)) else { continue } + refusedIndexRows.insert(row) + issues.append(SchemaDraftIssue(tab: .indexes, row: row, message: reason)) + } + guard columnRefusals.isEmpty else { + return CreateTableStatements(statements: [], issues: issues, tableName: definition.tableName) + } + guard let createTable = driver.generateCreateTableSQL(definition: definition) else { issues.append(SchemaDraftIssue( tab: .columns, row: nil, @@ -45,7 +57,7 @@ enum CreateTableStatementComposer { } var statements = [createTable] - for (row, index) in plan.indexes.enumerated() { + for (row, index) in plan.indexes.enumerated() where !refusedIndexRows.contains(row) { guard let sql = driver.generateAddIndexSQL(table: definition.tableName, index: index) else { issues.append(SchemaDraftIssue( tab: .indexes, row: row, diff --git a/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift b/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift new file mode 100644 index 0000000000..c7bd17f96f --- /dev/null +++ b/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift @@ -0,0 +1,36 @@ +// +// SchemaOperationRefusal.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct SchemaOperationRefusedError: LocalizedError, Equatable { + let reason: String + + var errorDescription: String? { reason } +} + +internal enum SchemaOperationRefusal { + static func reason(for change: SchemaChange, driver: any PluginDatabaseDriver) -> String? { + switch change { + case .addColumn(let column): + return driver.schemaOperationRefusal(.addColumn(column.toPlugin())) + case .addIndex(let index), .modifyIndex(_, let index): + return driver.schemaOperationRefusal(.addIndex(index.toPlugin())) + case .modifyCheckConstraint(let old, let new): + guard old.expression == new.expression, old.name != new.name else { return nil } + return driver.schemaOperationRefusal(.renameCheckConstraint(from: old.name, to: new.name)) + case .modifyColumn, .deleteColumn, .deleteIndex, .addForeignKey, .modifyForeignKey, + .deleteForeignKey, .modifyPrimaryKey, .addCheckConstraint, .deleteCheckConstraint: + return nil + } + } + + static func reason(for definition: PluginCreateTableDefinition, driver: any PluginDatabaseDriver) -> String? { + let operations = definition.columns.map(PluginSchemaOperation.addColumn) + + definition.indexes.map(PluginSchemaOperation.addIndex) + return operations.lazy.compactMap { driver.schemaOperationRefusal($0) }.first + } +} diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index a8e3e238d4..3e9d8191d2 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -45,6 +45,10 @@ struct SchemaStatementGenerator { var statements: [SchemaStatement] = [] let sortedChanges = sortByDependency(changes) + let refusals = sortedChanges.lazy.compactMap { SchemaOperationRefusal.reason(for: $0, driver: pluginDriver) } + if let reason = refusals.first { + throw SchemaOperationRefusedError(reason: reason) + } for change in sortedChanges { let stmts = try generateStatements(for: change) diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index e6032bd19a..ef96735e78 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -376,7 +376,7 @@ final class StructureChangeManager: ChangeManaging { // MARK: - Row-Specific Undo Delete /// Clear the deletion mark for the entity at `row` in `tab`. Mirrors - /// `DataChangeManager.undoRowDeletion(rowIndex:)`: the global NSUndoManager + /// `DataChangeManager.undoRowDeletion(rowID:)`: the global NSUndoManager /// stack is intentionally left alone. The original `applySchemaUndo(...)` /// handler the deletion registered remains on the stack; if global Cmd+Z /// later invokes it, the handler finds `pendingChanges` no longer marks @@ -733,12 +733,12 @@ final class StructureChangeManager: ChangeManaging { var rowChanges: [RowChange] { [] } - var insertedRowIndices: Set { [] } + var insertedRowIDs: Set { [] } - func isRowDeleted(_ rowIndex: Int) -> Bool { false } + func isRowDeleted(_ rowID: RowID) -> Bool { false } func recordCellChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, oldValue: PluginCellValue, @@ -746,9 +746,7 @@ final class StructureChangeManager: ChangeManaging { originalRow: [PluginCellValue]? ) {} - func undoRowDeletion(rowIndex: Int) {} - - func undoRowInsertion(rowIndex: Int) {} + func undoRowDeletion(rowID: RowID) {} } // MARK: - Schema Undo Action diff --git a/TablePro/Core/Scripting/ScriptingSnapshot.swift b/TablePro/Core/Scripting/ScriptingSnapshot.swift index 9d5910ba39..ff3679e5e3 100644 --- a/TablePro/Core/Scripting/ScriptingSnapshot.swift +++ b/TablePro/Core/Scripting/ScriptingSnapshot.swift @@ -203,15 +203,15 @@ internal enum ScriptingSnapshot { /// A row marked for deletion is still in the buffer and is deliberately not in the result the /// grid is showing, so it is not in what a script reads either. let deleted = isSelectedTab - ? coordinator.changeManager.deletedRowIndices - : tab.pendingChanges.deletedRowIndices + ? coordinator.changeManager.deletedRowIDs + : tab.pendingChanges.deletedRowIDs let tableRows = coordinator.tabSessionRegistry.tableRows(for: tabId) let read = DisplayedResultReader.read( tableRows: tableRows, displayIDs: coordinator.displayIDs(forTab: tabId), selectedDisplayIndices: selected, - deletedDisplayIndices: deleted, + deletedRowIDs: deleted, columns: .fromColumnLayout(tab.columnLayout, columns: tableRows.columns) ) let metadata = tab.display.activeResultSet.map { diff --git a/TablePro/Core/ServerDashboard/Providers/PostgreSQLActivityCatalog.swift b/TablePro/Core/ServerDashboard/Providers/PostgreSQLActivityCatalog.swift new file mode 100644 index 0000000000..8b0152eb1c --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/PostgreSQLActivityCatalog.swift @@ -0,0 +1,159 @@ +// +// PostgreSQLActivityCatalog.swift +// TablePro +// + +import Foundation + +/// Which of the five metrics an engine can answer. +/// +/// CockroachDB reports a PostgreSQL version and `pg_stat_activity`, but `pg_size_pretty()`, +/// `pg_database_size()` and `pg_postmaster_start_time()` are unknown functions there (measured on +/// v26.2), so asking for them every tick is three failed statements per refresh. +enum PostgreSQLDashboardMetricSet: Equatable, Sendable { + case full + case activityOnly + + init(databaseType: DatabaseType) { + self = databaseType == .cockroachdb ? .activityOnly : .full + } + + var identifiers: Set { + switch self { + case .full: + return ["connections", "cache_hit", "db_size", "uptime", "active_queries"] + case .activityOnly: + return ["connections", "active_queries"] + } + } +} + +enum PostgreSQLActivityCatalog: Equatable, Sendable { + case current + case withoutBackendType + case procpid + + init(serverVersion: PostgreSQLServerVersion?) { + guard let serverVersion else { + self = .current + return + } + if serverVersion >= .backendTypeColumn { + self = .current + } else if serverVersion >= .stateColumns { + self = .withoutBackendType + } else { + self = .procpid + } + } + + private static let idleSentinels = [ + "", + " in transaction", + " in transaction (aborted)" + ] + + private static let hiddenSentinels = idleSentinels + [ + "", + "" + ] + + private static func literalList(_ values: [String]) -> String { + values.map { "'\($0)'" }.joined(separator: ", ") + } + + var sessionsQuery: String { + switch self { + case .current: + return """ + SELECT pid, usename, datname, state, + EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, + left(query, 1000) AS query + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + AND backend_type = 'client backend' + ORDER BY query_start NULLS LAST + """ + case .withoutBackendType: + return """ + SELECT pid, usename, datname, state, + EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, + left(query, 1000) AS query + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + ORDER BY query_start NULLS LAST + """ + case .procpid: + return """ + SELECT procpid AS pid, usename, datname, + CASE current_query + WHEN '' THEN 'idle' + WHEN ' in transaction' THEN 'idle in transaction' + WHEN ' in transaction (aborted)' THEN 'idle in transaction (aborted)' + WHEN '' THEN 'disabled' + WHEN '' THEN NULL + ELSE 'active' + END AS state, + EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, + CASE WHEN current_query IN (\(Self.literalList(Self.idleSentinels))) THEN '' + ELSE left(current_query, 1000) + END AS query + FROM pg_stat_activity + WHERE procpid <> pg_backend_pid() + ORDER BY query_start NULLS LAST + """ + } + } + + var connectionCountQuery: String { + switch self { + case .current: + return "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'" + case .withoutBackendType, .procpid: + return "SELECT count(*) FROM pg_stat_activity" + } + } + + var activeQueryCountQuery: String { + switch self { + case .current, .withoutBackendType: + return """ + SELECT count(*) FROM pg_stat_activity + WHERE state = 'active' AND pid <> pg_backend_pid() + """ + case .procpid: + return """ + SELECT count(*) FROM pg_stat_activity + WHERE current_query NOT IN (\(Self.literalList(Self.hiddenSentinels))) + AND procpid <> pg_backend_pid() + """ + } + } + + var slowQueriesQuery: String { + switch self { + case .current, .withoutBackendType: + return """ + SELECT pid, usename, datname, + EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, + left(query, 1000) AS query + FROM pg_stat_activity + WHERE state = 'active' + AND now() - query_start > interval '1 second' + AND pid <> pg_backend_pid() + ORDER BY query_start + """ + case .procpid: + return """ + SELECT procpid AS pid, usename, datname, + EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, + left(current_query, 1000) AS query + FROM pg_stat_activity + WHERE current_query NOT IN (\(Self.literalList(Self.hiddenSentinels))) + AND now() - query_start > interval '1 second' + AND procpid <> pg_backend_pid() + ORDER BY query_start + """ + } + } +} diff --git a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift index 5595db563f..b86fde235a 100644 --- a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -4,22 +4,26 @@ // import Foundation +import os import TableProPluginKit struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { + private static let logger = Logger(subsystem: "com.TablePro", category: "PostgreSQLDashboardProvider") + let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] + let activityCatalog: PostgreSQLActivityCatalog + let metricSet: PostgreSQLDashboardMetricSet + + init( + activityCatalog: PostgreSQLActivityCatalog = .current, + metricSet: PostgreSQLDashboardMetricSet = .full + ) { + self.activityCatalog = activityCatalog + self.metricSet = metricSet + } func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { - let sql = """ - SELECT pid, usename, datname, state, - EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, - left(query, 1000) AS query - FROM pg_stat_activity - WHERE pid <> pg_backend_pid() - AND backend_type = 'client backend' - ORDER BY query_start NULLS LAST - """ - let result = try await execute(sql) + let result = try await execute(activityCatalog.sessionsQuery) let col = columnIndex(from: result.columns) return result.rows.map { row in let pid = value(row, at: col["pid"]) @@ -38,87 +42,37 @@ struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { var metrics: [DashboardMetric] = [] - - let connections = try await execute("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'") - if let row = connections.rows.first { - metrics.append(DashboardMetric( - id: "connections", - label: String(localized: "Connections"), - value: value(row, at: 0), - unit: "", - icon: "person.2" - )) + var firstFailure: Error? + + for definition in metricDefinitions { + do { + let result = try await execute(definition.query) + guard let row = result.rows.first else { continue } + metrics.append(DashboardMetric( + id: definition.id, + label: definition.label, + value: value(row, at: 0), + unit: definition.unit, + icon: definition.icon + )) + } catch is CancellationError { + throw CancellationError() + } catch { + Self.logger.warning( + "Metric \(definition.id, privacy: .public) failed: \(error.localizedDescription, privacy: .public)" + ) + if firstFailure == nil { firstFailure = error } + } } - let cacheHit = try await execute(""" - SELECT CASE WHEN blks_hit + blks_read = 0 THEN '0' - ELSE round(blks_hit::numeric / (blks_hit + blks_read) * 100, 1)::text - END - FROM pg_stat_database WHERE datname = current_database() - """) - if let row = cacheHit.rows.first { - metrics.append(DashboardMetric( - id: "cache_hit", - label: String(localized: "Cache Hit Ratio"), - value: value(row, at: 0), - unit: "%", - icon: "bolt" - )) + if metrics.isEmpty, let firstFailure { + throw firstFailure } - - let dbSize = try await execute("SELECT pg_size_pretty(pg_database_size(current_database()))") - if let row = dbSize.rows.first { - metrics.append(DashboardMetric( - id: "db_size", - label: String(localized: "Database Size"), - value: value(row, at: 0), - unit: "", - icon: "internaldrive" - )) - } - - let uptime = try await execute( - "SELECT date_trunc('second', now() - pg_postmaster_start_time())::text" - ) - if let row = uptime.rows.first { - metrics.append(DashboardMetric( - id: "uptime", - label: String(localized: "Uptime"), - value: value(row, at: 0), - unit: "", - icon: "clock" - )) - } - - let activeQueries = try await execute(""" - SELECT count(*) FROM pg_stat_activity - WHERE state = 'active' AND pid <> pg_backend_pid() - """) - if let row = activeQueries.rows.first { - metrics.append(DashboardMetric( - id: "active_queries", - label: String(localized: "Active Queries"), - value: value(row, at: 0), - unit: "", - icon: "bolt.horizontal" - )) - } - return metrics } func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { - let sql = """ - SELECT pid, usename, datname, - EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, - left(query, 1000) AS query - FROM pg_stat_activity - WHERE state = 'active' - AND now() - query_start > interval '1 second' - AND pid <> pg_backend_pid() - ORDER BY query_start - """ - let result = try await execute(sql) + let result = try await execute(activityCatalog.slowQueriesQuery) let col = columnIndex(from: result.columns) return result.rows.map { row in let secs = Int(value(row, at: col["duration_secs"])) ?? 0 @@ -142,6 +96,67 @@ struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { } } +// MARK: - Metrics + +private extension PostgreSQLDashboardProvider { + struct MetricDefinition { + let id: String + let label: String + let unit: String + let icon: String + let query: String + } + + var metricDefinitions: [MetricDefinition] { + allMetricDefinitions.filter { metricSet.identifiers.contains($0.id) } + } + + var allMetricDefinitions: [MetricDefinition] { + [ + MetricDefinition( + id: "connections", + label: String(localized: "Connections"), + unit: "", + icon: "person.2", + query: activityCatalog.connectionCountQuery + ), + MetricDefinition( + id: "cache_hit", + label: String(localized: "Cache Hit Ratio"), + unit: "%", + icon: "bolt", + query: """ + SELECT CASE WHEN blks_hit + blks_read = 0 THEN '0' + ELSE round(blks_hit::numeric / (blks_hit + blks_read) * 100, 1)::text + END + FROM pg_stat_database WHERE datname = current_database() + """ + ), + MetricDefinition( + id: "db_size", + label: String(localized: "Database Size"), + unit: "", + icon: "internaldrive", + query: "SELECT pg_size_pretty(pg_database_size(current_database()))" + ), + MetricDefinition( + id: "uptime", + label: String(localized: "Uptime"), + unit: "", + icon: "clock", + query: "SELECT date_trunc('second', now() - pg_postmaster_start_time())::text" + ), + MetricDefinition( + id: "active_queries", + label: String(localized: "Active Queries"), + unit: "", + icon: "bolt.horizontal", + query: activityCatalog.activeQueryCountQuery + ) + ] + } +} + // MARK: - Helpers private extension PostgreSQLDashboardProvider { diff --git a/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift index 06c4bd8b98..459da99224 100644 --- a/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift +++ b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift @@ -6,10 +6,17 @@ import Foundation enum ServerDashboardQueryProviderFactory { - static func provider(for databaseType: DatabaseType) -> ServerDashboardQueryProvider? { + static func provider(for databaseType: DatabaseType, serverVersion: String? = nil) -> ServerDashboardQueryProvider? { switch databaseType { - case .postgresql, .redshift, .cockroachdb: - return PostgreSQLDashboardProvider() + case .postgresql: + return PostgreSQLDashboardProvider( + activityCatalog: PostgreSQLActivityCatalog(serverVersion: PostgreSQLServerVersion(serverVersion)), + metricSet: PostgreSQLDashboardMetricSet(databaseType: databaseType) + ) + case .redshift, .cockroachdb: + return PostgreSQLDashboardProvider( + metricSet: PostgreSQLDashboardMetricSet(databaseType: databaseType) + ) case .mysql, .mariadb: return MySQLDashboardProvider() case .mssql: diff --git a/TablePro/Core/Services/Execution/DefaultExecutionGate.swift b/TablePro/Core/Services/Execution/DefaultExecutionGate.swift index 88b591a7ab..e662dd7c93 100644 --- a/TablePro/Core/Services/Execution/DefaultExecutionGate.swift +++ b/TablePro/Core/Services/Execution/DefaultExecutionGate.swift @@ -10,6 +10,7 @@ internal actor DefaultExecutionGate: ExecutionGate { private let authenticating: OperationAuthenticating private let safeModeLevelResolver: @Sendable (UUID) async -> SafeModeLevel private let forcesWriteResolver: @Sendable (DatabaseType) async -> Bool + private let connectionNameResolver: @Sendable (UUID) async -> String? private let auditLog: any ExecutionAuditLogging init( @@ -17,12 +18,14 @@ internal actor DefaultExecutionGate: ExecutionGate { authenticating: OperationAuthenticating, safeModeLevelResolver: @escaping @Sendable (UUID) async -> SafeModeLevel, forcesWriteResolver: @escaping @Sendable (DatabaseType) async -> Bool, + connectionNameResolver: @escaping @Sendable (UUID) async -> String? = { _ in nil }, auditLog: any ExecutionAuditLogging = ExecutionAuditLog.shared ) { self.confirming = confirming self.authenticating = authenticating self.safeModeLevelResolver = safeModeLevelResolver self.forcesWriteResolver = forcesWriteResolver + self.connectionNameResolver = connectionNameResolver self.auditLog = auditLog } @@ -76,10 +79,15 @@ internal actor DefaultExecutionGate: ExecutionGate { return .denied(reason: String(localized: "Confirmation is required for this operation")) } let confirmed = await confirming.confirm( - sql: request.sql ?? "", - operationDescription: request.operationDescription, - connectionId: request.connectionId, - isDestructive: isDestructive + OperationConfirmationRequest( + sql: request.sql, + operationDescription: request.operationDescription, + connectionId: request.connectionId, + connectionName: await connectionNameResolver(request.connectionId), + databaseType: request.databaseType, + caller: request.caller, + isDestructive: isDestructive + ) ) guard confirmed else { return .denied(reason: String(localized: "Operation cancelled by user")) diff --git a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift index 2c65d5114d..64eb686d0c 100644 --- a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift +++ b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift @@ -29,6 +29,18 @@ internal enum ExecutionGateProvider { await MainActor.run { !PluginManager.shared.supportsReadOnlyMode(for: databaseType) } + }, + connectionNameResolver: { connectionId in + await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live(_, let session): + return session.connection.name + case .stored(let connection): + return connection.name + case .unknown: + return nil + } + } } ) } diff --git a/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift b/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift index ed380824bc..22ae9dc27c 100644 --- a/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift +++ b/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift @@ -4,49 +4,110 @@ // import AppKit +import TableProPluginKit + +internal struct OperationConfirmationRequest: Sendable { + let sql: String? + let operationDescription: String + let connectionId: UUID + let connectionName: String? + let databaseType: DatabaseType + let caller: OperationCaller + let isDestructive: Bool +} internal protocol OperationConfirming: Sendable { @MainActor - func confirm(sql: String, operationDescription: String, connectionId: UUID, isDestructive: Bool) async -> Bool + func confirm(_ request: OperationConfirmationRequest) async -> Bool +} + +/// What the user is shown before a statement runs. Everything here is a pure function of the +/// request so the wording can be tested without presenting anything. +internal enum OperationConfirmationPrompt { + internal static let confirmTitle = String(localized: "Execute") + + internal static func statement(of request: OperationConfirmationRequest) -> String? { + guard let sql = request.sql?.trimmingCharacters(in: .whitespacesAndNewlines), !sql.isEmpty else { + return nil + } + return sql + } + + internal static func subtitle(of request: OperationConfirmationRequest) -> String { + let connection = request.connectionName?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let client = clientName(for: request.caller) else { + guard let connection, !connection.isEmpty else { + return String(localized: "Review this before it runs.") + } + return String(format: String(localized: "Runs on '%@'."), connection) + } + guard let connection, !connection.isEmpty else { + return String(format: String(localized: "%@ wants to run this."), client) + } + return String(format: String(localized: "%1$@ wants to run this on '%2$@'."), client, connection) + } + + internal static func destructiveWarning(of request: OperationConfirmationRequest) -> String? { + guard request.isDestructive else { return nil } + return String(localized: "This may permanently modify or delete data and cannot be undone.") + } + + /// A rename has no statement to show: the driver builds it from the names, and two engines + /// perform it without SQL at all. The review dialog would render an empty box, so this case + /// stays an alert, which is what an alert is for. + @MainActor + internal static func makeAlert(for request: OperationConfirmationRequest) -> NSAlert { + let alert = NSAlert() + alert.messageText = request.operationDescription + alert.informativeText = [subtitle(of: request), destructiveWarning(of: request)] + .compactMap { $0 } + .joined(separator: "\n\n") + alert.alertStyle = request.isDestructive ? .critical : .warning + AlertHelper.addConfirmAndCancel( + to: alert, + confirmButton: confirmTitle, + cancelButton: String(localized: "Cancel") + ) + return alert + } + + private static func clientName(for caller: OperationCaller) -> String? { + switch caller { + case .userInterface, .importPipeline, .backgroundMaintenance: + return nil + case .mcpClient(let label): + return label ?? String(localized: "An MCP client") + case .aiAssistant: + return String(localized: "The AI assistant") + case .appleScript(let client): + return client ?? String(localized: "Another app") + } + } } internal struct AlertOperationConfirming: OperationConfirming { @MainActor - func confirm(sql: String, operationDescription: String, connectionId: UUID, isDestructive: Bool) async -> Bool { + func confirm(_ request: OperationConfirmationRequest) async -> Bool { AppActivationPolicyController.shared.activate(ignoringOtherApps: true) - let window = WindowLifecycleMonitor.shared.activeWindow(for: connectionId, preferring: NSApp.keyWindow) - let preview = Self.preview(of: sql) - - if isDestructive { - return await AlertHelper.confirmCritical( - title: operationDescription, - message: String( - format: String(localized: "This query may permanently modify or delete data and cannot be undone.\n\n%@"), - preview - ), - confirmButton: String(localized: "Execute"), - cancelButton: String(localized: "Cancel"), - window: window - ) + let window = WindowLifecycleMonitor.shared.activeWindow( + for: request.connectionId, + preferring: NSApp.keyWindow + ) + + guard let statement = OperationConfirmationPrompt.statement(of: request) else { + let alert = OperationConfirmationPrompt.makeAlert(for: request) + return await AlertHelper.response(to: alert, in: window) == .alertFirstButtonReturn } - return await AlertHelper.confirmDestructive( - title: operationDescription, - message: String( - format: String(localized: "Are you sure you want to execute this query?\n\n%@"), - preview - ), - confirmButton: String(localized: "Execute"), - cancelButton: String(localized: "Cancel"), + return await AlertHelper.runStatementConfirmation( + title: request.operationDescription, + subtitle: OperationConfirmationPrompt.subtitle(of: request), + warning: OperationConfirmationPrompt.destructiveWarning(of: request), + statements: [statement], + databaseType: request.databaseType, + confirmTitle: OperationConfirmationPrompt.confirmTitle, + isDestructive: request.isDestructive, window: window ) } - - private static func preview(of sql: String) -> String { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - if (trimmed as NSString).length > 200 { - return String(trimmed.prefix(200)) + "…" - } - return trimmed - } } diff --git a/TablePro/Core/Services/Formatting/StoredBoolean.swift b/TablePro/Core/Services/Formatting/StoredBoolean.swift new file mode 100644 index 0000000000..c558be3cfa --- /dev/null +++ b/TablePro/Core/Services/Formatting/StoredBoolean.swift @@ -0,0 +1,29 @@ +// +// StoredBoolean.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +enum StoredBoolean { + static func value(of text: String) -> Bool? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + switch PluginSQLLiteral.booleanSynonym(for: trimmed) { + case .isTrue: + return true + case .isFalse: + return false + default: + break + } + switch trimmed.lowercased() { + case "t": + return true + case "f": + return false + default: + return nil + } + } +} diff --git a/TablePro/Core/Services/Highlight/HighlightCondition.swift b/TablePro/Core/Services/Highlight/HighlightCondition.swift new file mode 100644 index 0000000000..2f941ab2ad --- /dev/null +++ b/TablePro/Core/Services/Highlight/HighlightCondition.swift @@ -0,0 +1,255 @@ +// +// HighlightCondition.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct HighlightCondition { + static let searchLimit = 10_000 + + private enum ValueKind { + case numeric + case boolean + case text + } + + private struct Operand { + let text: String + let number: Decimal? + let boolean: Bool? + let isNullLiteral: Bool + + init(_ raw: String, allowsNullLiteral: Bool) { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + text = raw + number = HighlightCondition.number(from: trimmed) + boolean = StoredBoolean.value(of: trimmed) + isNullLiteral = allowsNullLiteral && HighlightCondition.isNullKeyword(trimmed) + } + } + + private let filterOperator: FilterOperator + private let valueKind: ValueKind + private let comparesCaseInsensitively: Bool + private let supportsEmptyString: Bool + private let operand: Operand + private let secondOperand: Operand + private let listOperands: [Operand] + private let regex: NSRegularExpression? + + init(rule: HighlightRule, columnType: ColumnType?) { + filterOperator = rule.filterOperator + valueKind = Self.valueKind(for: columnType) + comparesCaseInsensitively = rule.filterOperator.supportsCaseSensitivity && !rule.isCaseSensitive + supportsEmptyString = ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) + + let allowsNullLiteral = Self.allowsNullLiteral(for: columnType) + operand = Operand(rule.value, allowsNullLiteral: allowsNullLiteral) + secondOperand = Operand(rule.secondValue ?? "", allowsNullLiteral: allowsNullLiteral) + listOperands = rule.filterOperator == .inList || rule.filterOperator == .notInList + ? Self.listItems(rule.value).map { Operand($0, allowsNullLiteral: allowsNullLiteral) } + : [] + regex = rule.filterOperator == .regex + ? Self.regularExpression(rule.value, ignoresCase: comparesCaseInsensitively) + : nil + } + + func matches(_ value: PluginCellValue) -> Bool { + switch value { + case .null: + return matchesNull() + case .bytes: + return matchesBinary() + case .text(let text): + return matches(text: text) + } + } + + private func matchesNull() -> Bool { + switch filterOperator { + case .isNull, .isEmpty: + return true + case .equal: + return operand.isNullLiteral + case .inList: + return listOperands.contains { $0.isNullLiteral } + case .notEqual, .contains, .notContains, .startsWith, .endsWith, .greaterThan, .greaterOrEqual, + .lessThan, .lessOrEqual, .isNotNull, .isNotEmpty, .notInList, .between, .regex: + return false + } + } + + private func matchesBinary() -> Bool { + switch filterOperator { + case .isNotNull, .isNotEmpty: + return true + case .isNull, .isEmpty, .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, + .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .inList, .notInList, .between, .regex: + return false + } + } + + private func matches(text: String) -> Bool { + switch filterOperator { + case .equal: + return !operand.isNullLiteral && order(text, against: operand) == .orderedSame + case .notEqual: + return operand.isNullLiteral || order(text, against: operand) != .orderedSame + case .contains: + return contains(text) + case .notContains: + return !contains(text) + case .startsWith: + return hasAffix(text, anchoredAtEnd: false) + case .endsWith: + return hasAffix(text, anchoredAtEnd: true) + case .greaterThan: + return !operand.isNullLiteral && order(text, against: operand) == .orderedDescending + case .greaterOrEqual: + return !operand.isNullLiteral && order(text, against: operand) != .orderedAscending + case .lessThan: + return !operand.isNullLiteral && order(text, against: operand) == .orderedAscending + case .lessOrEqual: + return !operand.isNullLiteral && order(text, against: operand) != .orderedDescending + case .isNull: + return false + case .isNotNull: + return true + case .isEmpty: + return supportsEmptyString && text.isEmpty + case .isNotEmpty: + return !supportsEmptyString || !text.isEmpty + case .inList: + return listOperands.contains { !$0.isNullLiteral && order(text, against: $0) == .orderedSame } + case .notInList: + let values = listOperands.filter { !$0.isNullLiteral } + return !values.isEmpty && !values.contains { order(text, against: $0) == .orderedSame } + case .between: + return order(text, against: operand) != .orderedAscending + && order(text, against: secondOperand) != .orderedDescending + case .regex: + return matchesRegex(text) + } + } + + private func order(_ text: String, against operand: Operand) -> ComparisonResult { + if prefersNumbers, let lhs = Self.number(from: text), let rhs = operand.number { + return Self.compare(lhs, rhs) + } + if prefersBooleans, let lhs = StoredBoolean.value(of: text), let rhs = operand.boolean { + return Self.compare(lhs ? 1 : 0, rhs ? 1 : 0) + } + return text.compare(operand.text, options: comparesCaseInsensitively ? [.caseInsensitive] : [.literal]) + } + + private var prefersNumbers: Bool { + switch valueKind { + case .numeric: + return true + case .boolean: + return false + case .text: + return isOrderingOperator + } + } + + private var prefersBooleans: Bool { + switch valueKind { + case .numeric, .boolean: + return true + case .text: + return false + } + } + + private var isOrderingOperator: Bool { + switch filterOperator { + case .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .between: + return true + case .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, .isNull, .isNotNull, + .isEmpty, .isNotEmpty, .inList, .notInList, .regex: + return false + } + } + + private var searchOptions: String.CompareOptions { + comparesCaseInsensitively ? [.caseInsensitive] : [.literal] + } + + private func contains(_ text: String) -> Bool { + guard !operand.text.isEmpty else { return true } + return Self.searchable(text).range(of: operand.text, options: searchOptions) != nil + } + + private func hasAffix(_ text: String, anchoredAtEnd: Bool) -> Bool { + guard !operand.text.isEmpty else { return true } + let options = searchOptions.union(anchoredAtEnd ? [.anchored, .backwards] : [.anchored]) + return text.range(of: operand.text, options: options) != nil + } + + private func matchesRegex(_ text: String) -> Bool { + guard let regex else { return false } + let searchable = Self.searchable(text) as NSString + return regex.firstMatch( + in: searchable as String, + options: [], + range: NSRange(location: 0, length: searchable.length) + ) != nil + } + + private static func searchable(_ text: String) -> String { + let source = text as NSString + guard source.length > searchLimit else { return text } + let cut = source.rangeOfComposedCharacterSequence(at: searchLimit).location + return source.substring(to: cut) + } + + private static func regularExpression(_ pattern: String, ignoresCase: Bool) -> NSRegularExpression? { + guard !pattern.isEmpty, (pattern as NSString).length <= searchLimit else { return nil } + return try? NSRegularExpression(pattern: pattern, options: ignoresCase ? [.caseInsensitive] : []) + } + + private static func valueKind(for columnType: ColumnType?) -> ValueKind { + switch columnType { + case .integer, .decimal: + return .numeric + case .boolean: + return .boolean + case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .array, .none: + return .text + } + } + + private static func listItems(_ input: String) -> [String] { + input.split(separator: ",", omittingEmptySubsequences: true).compactMap { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } + } + + static func readsAsNullLiteral(_ text: String, columnType: ColumnType?) -> Bool { + allowsNullLiteral(for: columnType) && isNullKeyword(text.trimmingCharacters(in: .whitespaces)) + } + + private static func allowsNullLiteral(for columnType: ColumnType?) -> Bool { + !ColumnTypeSQLQuoting.isKnownTextLike(columnType) + } + + private static func isNullKeyword(_ text: String) -> Bool { + text.caseInsensitiveCompare("NULL") == .orderedSame + } + + static func number(from text: String) -> Decimal? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard PluginNumericLiteral.isValid(trimmed) else { return nil } + return Decimal(string: trimmed, locale: Locale(identifier: "en_US_POSIX")) + } + + private static func compare(_ lhs: Value, _ rhs: Value) -> ComparisonResult { + if lhs < rhs { return .orderedAscending } + if lhs > rhs { return .orderedDescending } + return .orderedSame + } +} diff --git a/TablePro/Core/Services/Highlight/HighlightRuleSet.swift b/TablePro/Core/Services/Highlight/HighlightRuleSet.swift new file mode 100644 index 0000000000..15cb302910 --- /dev/null +++ b/TablePro/Core/Services/Highlight/HighlightRuleSet.swift @@ -0,0 +1,93 @@ +// +// HighlightRuleSet.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct HighlightRuleSet { + struct Key: Equatable { + let rules: [HighlightRule] + let columns: [String] + let columnTypes: [ColumnType] + } + + private struct CompiledRule { + let rule: HighlightRule + let column: Int + let condition: HighlightCondition + } + + let key: Key + let unresolvedRuleIDs: Set + private let rowRules: [CompiledRule] + private let cellRules: [CompiledRule] + + static let empty = HighlightRuleSet(rules: [], columns: [], columnTypes: []) + + init(rules: [HighlightRule], columns: [String], columnTypes: [ColumnType]) { + key = Key(rules: rules, columns: columns, columnTypes: columnTypes) + + var rowRules: [CompiledRule] = [] + var cellRules: [CompiledRule] = [] + var unresolved = Set() + for rule in rules where rule.isEnabled && rule.isValid { + guard let column = Self.columnIndex( + named: rule.columnName, + occurrence: rule.columnOccurrence, + in: columns + ) else { + unresolved.insert(rule.id) + continue + } + let columnType = column < columnTypes.count ? columnTypes[column] : nil + let compiled = CompiledRule( + rule: rule, + column: column, + condition: HighlightCondition(rule: rule, columnType: columnType) + ) + switch rule.target { + case .row: + rowRules.append(compiled) + case .cell: + cellRules.append(compiled) + } + } + self.rowRules = rowRules + self.cellRules = cellRules + self.unresolvedRuleIDs = unresolved + } + + var isEmpty: Bool { rowRules.isEmpty && cellRules.isEmpty } + + func highlight(for values: ContiguousArray) -> RowHighlight { + guard !isEmpty else { return .none } + let rowRule = rowRules.first { Self.matches($0, in: values) }?.rule + var matchedCells: [Int: HighlightRule] = [:] + for compiled in cellRules where matchedCells[compiled.column] == nil && Self.matches(compiled, in: values) { + matchedCells[compiled.column] = compiled.rule + } + return RowHighlight(rowRule: rowRule, cellRules: matchedCells) + } + + static func columnIndex(named name: String, occurrence: Int, in columns: [String]) -> Int? { + var seen = 0 + for (index, column) in columns.enumerated() where column == name { + if seen == occurrence { return index } + seen += 1 + } + return nil + } + + static func occurrence(ofColumnAt index: Int, in columns: [String]) -> Int { + guard index >= 0, index < columns.count else { return 0 } + let name = columns[index] + return columns[..) -> Bool { + guard compiled.column < values.count else { return false } + return compiled.condition.matches(values[compiled.column]) + } +} diff --git a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index 731c6e701c..26d176bccb 100644 --- a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -113,6 +113,7 @@ internal enum EditorTabOpener { databaseName: payload.databaseName ?? browseDatabaseName, schemaName: resolvedSchemaName, isView: payload.isView, + objectType: payload.objectType, isPreview: payload.isPreview, allowsDuplicate: payload.forcesNewTab ) @@ -126,6 +127,7 @@ internal enum EditorTabOpener { /// work the user did there while leaving the grid on rows the new filter never ran. guard didCreateTab, let index = tabManager.selectedTabIndex else { return } tabManager.tabs[index].tableContext.isView = payload.isView + tabManager.tabs[index].tableContext.objectType = payload.objectType tabManager.tabs[index].tableContext.isEditable = !payload.isView tabManager.tabs[index].tableContext.schemaName = resolvedSchemaName if payload.showStructure { diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift index df58cf61b5..36ef59e49b 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProPluginKit extension MainSplitViewController { @objc func switchConnection(_ sender: Any?) { @@ -88,8 +89,25 @@ extension MainSplitViewController { commandActions?.editViewDefinition() } + @objc func showObjectDDL(_ sender: Any?) { + commandActions?.showObjectDDL() + } + + @objc func copyObjectDDL(_ sender: Any?) { + commandActions?.copyObjectDDL() + } + + @objc func refreshMaterializedView(_ sender: Any?) { + commandActions?.refreshMaterializedView() + } + + @objc func editObjectComment(_ sender: Any?) { + commandActions?.editObjectComment() + } + @objc func runMaintenanceOperation(_ sender: Any?) { - guard let operation = (sender as? NSMenuItem)?.representedObject as? String else { return } + guard let operation = (sender as? NSMenuItem)?.representedObject as? PluginMaintenanceOperation + else { return } commandActions?.runMaintenanceOperation(operation) } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 2a11ff404c..e5f15e41ed 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -21,6 +21,7 @@ struct MenuValidationContext: Equatable { var canUseGridFindCommands = false /// Jump to Column reads the mounted data grid, so it needs one on screen with columns to list. var canJumpToColumn = false + var canPresentHighlightRules = false /// Save As writes the selected tab's SQL, so it needs a query tab and not merely a connection. var isQueryTab = false /// Export Results exports the selected tab's rows, so an empty grid has nothing to offer. @@ -67,6 +68,9 @@ struct MenuValidationContext: Equatable { var canReleaseFileLock = false var canShowTableStructure = false var canEditViewDefinition = false + var canShowObjectDDL = false + var canRefreshMaterializedView = false + var canEditObjectComment = false var canCreateDatabase = false var canCopyObjects = false var canDuplicateDatabase = false @@ -228,10 +232,13 @@ extension MainSplitViewController: NSMenuItemValidation { return context.canCopyObjects case #selector(duplicateCurrentDatabase(_:)): return context.canDuplicateDatabase - case #selector(showTableStructure(_:)): - return context.isConnected && context.canShowTableStructure - case #selector(editViewDefinition(_:)): - return context.isConnected && context.canEditViewDefinition + case #selector(showTableStructure(_:)), + #selector(editViewDefinition(_:)), + #selector(showObjectDDL(_:)), + #selector(copyObjectDDL(_:)), + #selector(refreshMaterializedView(_:)), + #selector(editObjectComment(_:)): + return objectCommandIsEnabled(selector, context: context) case #selector(runMaintenanceOperation(_:)): return context.isConnected && context.hasMaintenanceOperations case #selector(switchToSchema(_:)): @@ -261,6 +268,8 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(toggleFilterBar(_:)): return context.isConnected && context.canUseTableResultCommands + case #selector(showHighlightRules(_:)): + return context.isConnected && context.canPresentHighlightRules case #selector(pinResult(_:)): return context.canPinResultTab case #selector(navigateBack(_:)): @@ -281,6 +290,27 @@ extension MainSplitViewController: NSMenuItemValidation { } } + /// The commands that act on the object selected in the sidebar. They answer on the same facts + /// the sidebar's own contextual menu reads, so a command the sidebar omits is dimmed here rather + /// than enabled over an object it cannot act on. + private static func objectCommandIsEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool { + guard context.isConnected else { return false } + switch selector { + case #selector(showTableStructure(_:)): + return context.canShowTableStructure + case #selector(editViewDefinition(_:)): + return !context.isReadOnly && context.canEditViewDefinition + case #selector(showObjectDDL(_:)), #selector(copyObjectDDL(_:)): + return context.canShowObjectDDL + case #selector(refreshMaterializedView(_:)): + return context.canRefreshMaterializedView + case #selector(editObjectComment(_:)): + return context.canEditObjectComment + default: + return false + } + } + /// The workspace-rail facts come from the window in both branches. They are true of the window, /// not of the connection it happens to be showing, and reading them off a connection that has /// no coordinator left disabled the only menu route to the window's other connections. @@ -298,6 +328,7 @@ extension MainSplitViewController: NSMenuItemValidation { canUseTableResultCommands: actions.canUseTableResultCommands, canUseGridFindCommands: actions.canUseGridFindCommands, canJumpToColumn: actions.canJumpToColumn, + canPresentHighlightRules: actions.canPresentHighlightRules, isQueryTab: actions.isQueryTab, hasResultRows: actions.hasResultRows, isCurrentTabEditable: actions.isCurrentTabEditable, @@ -325,6 +356,9 @@ extension MainSplitViewController: NSMenuItemValidation { canReleaseFileLock: canReleaseFileLock, canShowTableStructure: actions.canShowTableStructure, canEditViewDefinition: actions.canEditViewDefinition, + canShowObjectDDL: actions.canShowObjectDDL, + canRefreshMaterializedView: actions.canRefreshMaterializedView, + canEditObjectComment: actions.canEditObjectComment, canCreateDatabase: actions.canCreateDatabase, canCopyObjects: actions.canCopyObjects, canDuplicateDatabase: actions.canDuplicateDatabase, diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift index 39f1496233..f454ac4ce5 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift @@ -63,6 +63,10 @@ extension MainSplitViewController { commandActions?.toggleFilterPanel() } + @objc func showHighlightRules(_ sender: Any?) { + commandActions?.showHighlightRules() + } + @objc func toggleQueryHistory(_ sender: Any?) { commandActions?.toggleHistoryPanel() } @@ -96,10 +100,18 @@ extension MainSplitViewController { } @objc func increaseEditorTextSize(_ sender: Any?) { - ThemeEngine.shared.adjustEditorFontSize(by: 1) + adjustEditorTextSize(by: 1) } @objc func decreaseEditorTextSize(_ sender: Any?) { - ThemeEngine.shared.adjustEditorFontSize(by: -1) + adjustEditorTextSize(by: -1) + } + + private func adjustEditorTextSize(by delta: Int) { + var typography = AppSettingsManager.shared.typography + let size = TypographySettings.clamp(typography.editorFontSize + delta) + guard size != typography.editorFontSize else { return } + typography.editorFontSize = size + AppSettingsManager.shared.typography = typography } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f1b3c867f8..f6802b77e0 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -714,8 +714,8 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// intermediate value instead of letting one run-loop turn settle on the final one. private func refreshPanes(of workspace: ConnectionWorkspace) { workspace.panes.sidebar.rootView = AnyView(buildSidebarView(for: workspace)) - workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace)) - workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace)) + workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace).themedContent()) + workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace).themedContent()) workspace.panes.assistant.rootView = AnyView(buildAssistantView(for: workspace)) refreshTabStripPane(of: workspace) workspace.panes.markRendered(workspace.paneRenderKey) @@ -903,7 +903,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// publishes those actions. func rebuildTrailingPanes() { guard let selected = workspaces.selected else { return } - selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected)) + selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected).themedContent()) selected.panes.assistant.rootView = AnyView(buildAssistantView(for: selected)) } diff --git a/TablePro/Core/Services/Infrastructure/RecentlyClosedTabReopener.swift b/TablePro/Core/Services/Infrastructure/RecentlyClosedTabReopener.swift index f4c24eaa55..9eec6dbf97 100644 --- a/TablePro/Core/Services/Infrastructure/RecentlyClosedTabReopener.swift +++ b/TablePro/Core/Services/Infrastructure/RecentlyClosedTabReopener.swift @@ -70,6 +70,7 @@ internal enum RecentlyClosedTabReopener { databaseName: tab.tableContext.databaseName, schemaName: tab.tableContext.schemaName, isView: tab.tableContext.isView, + objectType: tab.tableContext.objectType, skipAutoExecute: true, sourceFileURL: tab.content.sourceFileURL, erDiagramSchemaKey: tab.display.erDiagramSchemaKey, diff --git a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift index 4e4c56d551..925489ae79 100644 --- a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit enum DockerComposeExtractor { struct ServiceDatabase { @@ -70,6 +71,16 @@ enum DockerComposeExtractor { "datafuselabs/databend-query", "databendlabs/databend-query", ] + /// The `oceanbase` organization also publishes OCP, obagent, the config server and miniob, none of + /// which speak the MySQL protocol, so the repository is matched rather than the whole image name. + /// OBProxy serves SQL on 2883, the observer on 2881. + private static let oceanbaseRepositories: [String: Int] = [ + "oceanbase/oceanbase-ce": 2_881, + "oceanbase/oceanbase": 2_881, + "oceanbase/obproxy-ce": 2_883, + "oceanbase/obproxy": 2_883, + ] + static func databaseKind(for image: String) -> ServiceDatabase? { let name = image.lowercased() let repositoryPath = repositoryComponents(of: name) @@ -79,6 +90,9 @@ enum DockerComposeExtractor { if databendRepositories.contains(repositoryPath.suffix(2).joined(separator: "/")) { return ServiceDatabase(type: .databend, defaultPort: 3_307) } + if let port = oceanbaseRepositories[repositoryPath.suffix(2).joined(separator: "/")] { + return ServiceDatabase(type: .oceanbase, defaultPort: port) + } if name.contains("postgres"), !name.contains("postgrest") { return ServiceDatabase(type: .postgresql, defaultPort: 5_432) } @@ -103,6 +117,18 @@ enum DockerComposeExtractor { return nil } + /// OBProxy routes by a cluster the observer's own port does not need, and `root@sys` reaches it + /// only when the proxy was given a default cluster, so the name goes into the username whenever + /// the compose file states it. + private static func applyOceanBaseCredentials(_ fields: inout ScannedConnectionFields, variables: [String: String]) { + let tenantPassword = variables["OB_TENANT_PASSWORD"]?.nilIfEmpty + let tenant = variables["OB_TENANT_NAME"]?.nilIfEmpty ?? (tenantPassword != nil ? "test" : "sys") + let cluster = variables["OB_CLUSTER_NAME"]?.nilIfEmpty + fields.username = "root@\(tenant)" + (cluster.map { "#\($0)" } ?? "") + fields.password = tenant == "sys" ? variables["OB_SYS_PASSWORD"] ?? "" : tenantPassword ?? "" + fields.database = variables["OB_DATABASE"] ?? "" + } + static func repositoryComponents(of image: String) -> [String] { let withoutDigest = image.split(separator: "@", maxSplits: 1).first.map(String.init) ?? image var components = withoutDigest.split(separator: "/").map(String.init) @@ -179,6 +205,8 @@ enum DockerComposeExtractor { fields.username = variables["QUERY_DEFAULT_USER"] ?? "root" fields.password = variables["QUERY_DEFAULT_PASSWORD"] ?? "" fields.database = "default" + case .oceanbase: + applyOceanBaseCredentials(&fields, variables: variables) case .mariadb, .mysql: let prefix = variables["MARIADB_PASSWORD"] != nil || variables["MARIADB_DATABASE"] != nil ? "MARIADB" diff --git a/TablePro/Core/Services/Query/ForeignKeyLookupService.swift b/TablePro/Core/Services/Query/ForeignKeyLookupService.swift index 8f1e7a294d..754ba09d52 100644 --- a/TablePro/Core/Services/Query/ForeignKeyLookupService.swift +++ b/TablePro/Core/Services/Query/ForeignKeyLookupService.swift @@ -72,6 +72,7 @@ enum ForeignKeyLookupService { label: label, searchTerm: term, dialect: dialect, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType), quoteIdentifier: driver.quoteIdentifier ) else { return [] diff --git a/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift b/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift index cb9fb53a4f..1f799d133c 100644 --- a/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift +++ b/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift @@ -52,6 +52,7 @@ enum ForeignKeyRowFetcher { quotedTable: quotedTable, quotedColumn: driver.quoteIdentifier(reference.referencedColumn), escapedValue: driver.escapeStringLiteral(value), + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType), dialect: PluginManager.shared.sqlDialect(for: databaseType) ) diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 575b8c220b..67997c4015 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -146,6 +146,18 @@ final class MetadataConnectionPool { internal var hasSweeper: Bool { sweeper != nil } + + /// The sweeper is started by opening a pooled connection, which a test with no plugin cannot + /// do, so a test that wants to watch it stop has to start it the way `openEntry` does. + internal func startSweeperForTesting() { + startSweeperIfNeeded() + } + + /// A pool of its own, so a test that moves the clock or empties the pool cannot close the + /// entries another test injected into the shared one. + internal static func isolatedForTesting() -> MetadataConnectionPool { + MetadataConnectionPool() + } #endif private func releaseEntry(_ entry: Entry) { @@ -173,7 +185,7 @@ final class MetadataConnectionPool { /// closed. The old entry is closed rather than left behind: overwriting `entries[key]` /// with a fresh one used to leak the driver it replaced. if let entry = entries[key] { - if entry.driver.status == .connected, !Self.isStale(entry.lastUsed) { + if entry.driver.status == .connected, !entry.driver.hasLostConnection, !Self.isStale(entry.lastUsed) { return entry } closeOrDeferEntry(forKey: key) diff --git a/TablePro/Core/Services/Query/QueryPlanParser.swift b/TablePro/Core/Services/Query/QueryPlanParser.swift index c8d17bcacb..7c249aaa62 100644 --- a/TablePro/Core/Services/Query/QueryPlanParser.swift +++ b/TablePro/Core/Services/Query/QueryPlanParser.swift @@ -31,7 +31,7 @@ struct PostgreSQLPlanParser: QueryPlanParser { } let planningTime = planDict["Planning Time"] as? Double - let executionTime = planDict["Execution Time"] as? Double + let executionTime = (planDict["Execution Time"] ?? planDict["Total Runtime"]) as? Double let rootNode = parseNode(plan) var queryPlan = QueryPlan( diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 57c75d5f10..545c48c677 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -10,7 +10,7 @@ final class RowOperationsManager { static let maxClipboardRows = 50_000 struct AddNewRowResult { - let rowIndex: Int + let rowID: RowID let values: [PluginCellValue] let delta: Delta } @@ -19,10 +19,11 @@ final class RowOperationsManager { let nextRowToSelect: Int let physicallyRemovedIndices: [Int] let delta: Delta + var stagedRowCount: Int = 0 } struct PastedRowInfo { - let rowIndex: Int + let rowID: RowID let values: [PluginCellValue] } @@ -36,11 +37,6 @@ final class RowOperationsManager { let delta: Delta } - struct UndoInsertRowResult { - let adjustedSelection: Set - let delta: Delta - } - private let changeManager: DataChangeManager init(changeManager: DataChangeManager) { @@ -56,13 +52,7 @@ final class RowOperationsManager { newRowValues.append(.null) } } - - let newRowIndex = tableRows.count - let delta = tableRows.appendInsertedRow(values: newRowValues) - - changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newRowValues) - - return AddNewRowResult(rowIndex: newRowIndex, values: newRowValues, delta: delta) + return appendInsertedRow(values: newRowValues, to: &tableRows) } func duplicateRow( @@ -83,191 +73,137 @@ final class RowOperationsManager { newValues[index] = .text("__DEFAULT__") } } + return appendInsertedRow(values: newValues, to: &tableRows) + } - let newRowIndex = tableRows.count - let delta = tableRows.appendInsertedRow(values: newValues) - - changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newValues) - - return AddNewRowResult(rowIndex: newRowIndex, values: newValues, delta: delta) + private func appendInsertedRow(values: [PluginCellValue], to tableRows: inout TableRows) -> AddNewRowResult { + let rowID = RowID.inserted(UUID()) + let delta = tableRows.appendInsertedRow(id: rowID, values: values) + changeManager.recordRowInsertion(rowID: rowID, values: values) + return AddNewRowResult(rowID: rowID, values: values, delta: delta) } func deleteSelectedRows( selectedIndices: Set, + displayIDs: [RowID]? = nil, tableRows: inout TableRows ) -> DeleteRowsResult { guard !selectedIndices.isEmpty else { return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: [], delta: .none) } - var insertedRowsToDelete: [Int] = [] - var existingRowsToDelete: [(rowIndex: Int, originalRow: [PluginCellValue])] = [] - - let minSelectedRow = selectedIndices.min() ?? 0 - let maxSelectedRow = selectedIndices.max() ?? 0 - - for rowIndex in selectedIndices.sorted(by: >) { - if changeManager.isRowInserted(rowIndex) { - insertedRowsToDelete.append(rowIndex) - } else if !changeManager.isRowDeleted(rowIndex) { - if rowIndex < tableRows.count { - existingRowsToDelete.append((rowIndex: rowIndex, originalRow: Array(tableRows.rows[rowIndex].values))) - } + let displayCountBefore = displayIDs?.count ?? tableRows.count + var insertedRowsToRemove: [InsertedRowLocation] = [] + var existingRowsToDelete: [(rowID: RowID, originalRow: [PluginCellValue])] = [] + + for displayIndex in selectedIndices.sorted(by: >) { + guard let storageIndex = DisplayRowMapping.rowIndex( + forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows + ) else { continue } + let row = tableRows.rows[storageIndex] + if row.id.isInserted { + insertedRowsToRemove.append(InsertedRowLocation(rowID: row.id, storageIndex: storageIndex)) + } else if !changeManager.isRowDeleted(row.id) { + existingRowsToDelete.append((rowID: row.id, originalRow: Array(row.values))) } } - let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) - var delta: Delta = .none - if !sortedInsertedRows.isEmpty { - delta = tableRows.remove(at: IndexSet(sortedInsertedRows)) - changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) + if !insertedRowsToRemove.isEmpty { + delta = tableRows.remove(at: IndexSet(insertedRowsToRemove.map(\.storageIndex))) + changeManager.undoBatchRowInsertion(rows: insertedRowsToRemove) } if !existingRowsToDelete.isEmpty { changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) } - let totalRows = tableRows.count - let rowsDeleted = sortedInsertedRows.count - let adjustedMaxRow = maxSelectedRow - rowsDeleted - let adjustedMinRow = minSelectedRow - sortedInsertedRows.count(where: { $0 < minSelectedRow }) - - let nextRow: Int - if adjustedMaxRow + 1 < totalRows { - nextRow = min(adjustedMaxRow + 1, totalRows - 1) - } else if adjustedMinRow > 0 { - nextRow = adjustedMinRow - 1 - } else if totalRows > 0 { - nextRow = 0 - } else { - nextRow = -1 - } - return DeleteRowsResult( - nextRowToSelect: nextRow, - physicallyRemovedIndices: sortedInsertedRows, - delta: delta + nextRowToSelect: Self.nextRowToSelect( + afterDeleting: selectedIndices, + removedCount: insertedRowsToRemove.count, + displayCountAfter: displayCountBefore - insertedRowsToRemove.count + ), + physicallyRemovedIndices: insertedRowsToRemove.map(\.storageIndex), + delta: delta, + stagedRowCount: insertedRowsToRemove.count + existingRowsToDelete.count ) } - func deleteRows( - existingRows: [(displayIndex: Int, originalRow: [PluginCellValue])], - insertedStorageIndices: [Int], - tableRows: inout TableRows - ) -> DeleteRowsResult { - let sortedInsertedRows = insertedStorageIndices.sorted(by: >) - - var delta: Delta = .none - if !sortedInsertedRows.isEmpty { - delta = tableRows.remove(at: IndexSet(sortedInsertedRows)) - changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) + private static func nextRowToSelect( + afterDeleting selectedIndices: Set, + removedCount: Int, + displayCountAfter: Int + ) -> Int { + let minSelectedRow = selectedIndices.min() ?? 0 + let adjustedMaxRow = (selectedIndices.max() ?? 0) - removedCount + if adjustedMaxRow + 1 < displayCountAfter { + return adjustedMaxRow + 1 } - - if !existingRows.isEmpty { - changeManager.recordBatchRowDeletion( - rows: existingRows.map { (rowIndex: $0.displayIndex, originalRow: $0.originalRow) } - ) + if minSelectedRow > 0 { + return minSelectedRow - 1 } - - return DeleteRowsResult( - nextRowToSelect: -1, - physicallyRemovedIndices: sortedInsertedRows, - delta: delta - ) + return displayCountAfter > 0 ? 0 : -1 } - /// `displayIDs` is the grid's display order when a per-column value filter is narrowing it. - /// - /// A cell edit is tracked by its display row, the same as the modified and deleted marks the - /// grid draws, but `TableRows` is indexed by storage position. The forward write converts; this - /// one has to as well, or undoing an edit under a filter puts the old value into whichever row - /// happens to sit at that storage offset. The row arms need no conversion: an inserted row is - /// tracked by its storage index from the start, which is what lets it be physically removed. - func applyUndoResult( - _ result: UndoResult, - displayIDs: [RowID]?, - tableRows: inout TableRows - ) -> UndoApplicationResult { + func applyUndoResult(_ result: UndoResult, tableRows: inout TableRows) -> UndoApplicationResult { switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _): - guard let storageRow = DisplayRowMapping.rowIndex( - forDisplay: rowIndex, displayIDs: displayIDs, in: tableRows - ) else { + case .cellEdit(let rowID, let columnIndex, _, let previousValue, _, _): + guard let storageRow = tableRows.index(of: rowID) else { return UndoApplicationResult(adjustedSelection: nil, delta: .none) } let delta = tableRows.edit(row: storageRow, column: columnIndex, value: previousValue) return UndoApplicationResult(adjustedSelection: nil, delta: delta) - case .rowInsertion(let rowIndex): + case .rowInsertion(let rowID): if result.needsRowRemoval { - guard rowIndex >= 0, rowIndex < tableRows.count else { + let delta = tableRows.remove(rowIDs: [rowID]) + guard delta != .none else { return UndoApplicationResult(adjustedSelection: nil, delta: .none) } - let delta = tableRows.remove(at: IndexSet(integer: rowIndex)) return UndoApplicationResult(adjustedSelection: Set(), delta: delta) - } else if result.needsRowRestore { - let columnCount = tableRows.columns.count - let values = result.restoreRow ?? [PluginCellValue](repeating: .null, count: columnCount) - let delta = tableRows.insertInsertedRow(at: rowIndex, values: values) + } + if result.needsRowRestore { + let values = result.restoreRow + ?? [PluginCellValue](repeating: .null, count: tableRows.columns.count) + let delta = tableRows.appendInsertedRow(id: rowID, values: values) return UndoApplicationResult(adjustedSelection: nil, delta: delta) } return UndoApplicationResult(adjustedSelection: nil, delta: .none) - case .rowDeletion: - return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) - - case .batchRowDeletion: + case .rowDeletion, .batchRowDeletion: return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) - case .batchRowInsertion(let rowIndices, let rowValues): + case .batchRowInsertion(let rows, let rowValues): if result.needsRowRemoval { - let validIndices = IndexSet(rowIndices.filter { $0 >= 0 && $0 < tableRows.count }) - guard !validIndices.isEmpty else { - return UndoApplicationResult(adjustedSelection: nil, delta: .none) - } - let delta = tableRows.remove(at: validIndices) + let delta = tableRows.remove(rowIDs: Set(rows.map(\.rowID))) return UndoApplicationResult(adjustedSelection: nil, delta: delta) - } else if result.needsRowRestore { - var insertedIndices = IndexSet() - let pairs = zip(rowIndices, rowValues).sorted { $0.0 < $1.0 } - for (rowIndex, values) in pairs { - guard rowIndex >= 0, rowIndex <= tableRows.count else { continue } - _ = tableRows.insertInsertedRow(at: rowIndex, values: values) - insertedIndices.insert(rowIndex) - } - guard !insertedIndices.isEmpty else { - return UndoApplicationResult(adjustedSelection: nil, delta: .none) - } - return UndoApplicationResult(adjustedSelection: nil, delta: .rowsInserted(insertedIndices)) + } + if result.needsRowRestore { + return UndoApplicationResult( + adjustedSelection: nil, + delta: restoreInsertedRows(rows, values: rowValues, into: &tableRows) + ) } return UndoApplicationResult(adjustedSelection: nil, delta: .none) } } - func undoInsertRow( - at rowIndex: Int, - tableRows: inout TableRows, - selectedIndices: Set - ) -> UndoInsertRowResult { - guard rowIndex >= 0 && rowIndex < tableRows.count else { - return UndoInsertRowResult(adjustedSelection: selectedIndices, delta: .none) - } - - let delta = tableRows.remove(at: IndexSet(integer: rowIndex)) - - var adjustedSelection = Set() - for idx in selectedIndices { - if idx == rowIndex { + private func restoreInsertedRows( + _ rows: [InsertedRowLocation], + values rowValues: [[PluginCellValue]], + into tableRows: inout TableRows + ) -> Delta { + var insertedIndices = IndexSet() + let ascending = zip(rows, rowValues).sorted { $0.0.storageIndex < $1.0.storageIndex } + for (location, values) in ascending { + let index = min(location.storageIndex, tableRows.count) + guard tableRows.insertInsertedRow(at: index, id: location.rowID, values: values) != .none else { continue - } else if idx > rowIndex { - adjustedSelection.insert(idx - 1) - } else { - adjustedSelection.insert(idx) } + insertedIndices.insert(index) } - - return UndoInsertRowResult(adjustedSelection: adjustedSelection, delta: delta) + return insertedIndices.isEmpty ? .none : .rowsInserted(insertedIndices) } func copySelectedRowsToClipboard( @@ -437,13 +373,9 @@ final class RowOperationsManager { for index in serverOwned where index < rowValues.count { rowValues[index] = .text("__DEFAULT__") } - let newRowIndex = tableRows.count - _ = tableRows.appendInsertedRow(values: rowValues) - insertedIndices.insert(newRowIndex) - - changeManager.recordRowInsertion(rowIndex: newRowIndex, values: rowValues) - - pastedRowInfo.append(PastedRowInfo(rowIndex: newRowIndex, values: rowValues)) + insertedIndices.insert(tableRows.count) + let inserted = appendInsertedRow(values: rowValues, to: &tableRows) + pastedRowInfo.append(PastedRowInfo(rowID: inserted.rowID, values: rowValues)) } let delta: Delta = insertedIndices.isEmpty ? .none : .rowsInserted(insertedIndices) diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index 2c81faffda..ac2c9aac5f 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -128,7 +128,8 @@ struct TableQueryBuilder { if let dialect { let activeFilters = filters.filter { $0.isEnabled } let filterGen = FilterSQLGenerator( - dialect: dialect, columns: columns, columnTypes: columnTypes, quoteIdentifier: dialectQuote + dialect: dialect, columns: columns, columnTypes: columnTypes, quoteIdentifier: dialectQuote, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType) ) let whereClause = filterGen.generateWhereClause(from: activeFilters, logicMode: logicMode) if !whereClause.isEmpty { @@ -196,7 +197,8 @@ struct TableQueryBuilder { let quotedTable = qualifiedTable(tableName, schema: schemaName) let activeFilters = filters.filter { $0.isEnabled } let filterGen = FilterSQLGenerator( - dialect: dialect, columns: columns, columnTypes: columnTypes, quoteIdentifier: dialectQuote + dialect: dialect, columns: columns, columnTypes: columnTypes, quoteIdentifier: dialectQuote, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType) ) let whereClause = filterGen.generateWhereClause(from: activeFilters, logicMode: logicMode) diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 37c77c8234..98f6d6947f 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -26,7 +26,7 @@ final class AppSettingsManager { var appearance: AppearanceSettings { didSet { storage.saveAppearance(appearance) - themeEngine.updateAppearanceAndTheme( + themeEngine.apply( mode: appearance.appearanceMode, lightThemeId: appearance.preferredLightThemeId, darkThemeId: appearance.preferredDarkThemeId @@ -38,19 +38,21 @@ final class AppSettingsManager { var editor: EditorSettings { didSet { storage.saveEditor(editor) - themeEngine.updateEditorSettings( - highlightCurrentLine: editor.highlightCurrentLine, - highlightCurrentStatement: editor.highlightCurrentStatement, - showLineNumbers: editor.showLineNumbers, - tabWidth: editor.clampedTabWidth, - - wordWrap: editor.wordWrap - ) appEvents.editorSettingsChanged.send(()) syncTracker.markDirty(.settings, id: AppSettingsCategory.editor) } } + /// Device-local: it is deliberately absent from `AppSettingsCategory.synced`, so a zoom press + /// never pushes an iCloud record and a Mac on an older build cannot reset it. + var typography: TypographySettings { + didSet { + guard typography != oldValue else { return } + storage.saveTypography(typography) + themeEngine.apply(typography: typography) + } + } + var notifications: NotificationSettings { didSet { guard !isValidating else { return } @@ -242,23 +244,17 @@ final class AppSettingsManager { self.sync = storage.loadSync() self.mcp = storage.loadMCP() self.notifications = storage.loadNotifications() + self.typography = storage.loadTypography() general.language.apply() - themeEngine.updateAppearanceAndTheme( + themeEngine.apply(typography: typography) + themeEngine.apply( mode: appearance.appearanceMode, lightThemeId: appearance.preferredLightThemeId, darkThemeId: appearance.preferredDarkThemeId ) - themeEngine.updateEditorSettings( - highlightCurrentLine: editor.highlightCurrentLine, - highlightCurrentStatement: editor.highlightCurrentStatement, - showLineNumbers: editor.showLineNumbers, - tabWidth: editor.clampedTabWidth, - wordWrap: editor.wordWrap - ) - dateFormattingService.updateFormat(dataGrid.dateFormat) if ai.enabled, ai.providers.contains(where: { $0.type == .copilot }) { @@ -296,6 +292,7 @@ final class AppSettingsManager { ai = .default sync = .default mcp = .default + typography = .default storage.resetToDefaults() } } diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index b3f28ebbce..436f5ba826 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -32,6 +32,7 @@ final class AppSettingsStorage: Sendable { static let sync = "com.TablePro.settings.sync" static let mcp = "com.TablePro.settings.mcp" static let notifications = "com.TablePro.settings.notifications" + static let typography = "com.TablePro.settings.typography" static let hasSeenWelcomeSheet = "com.TablePro.settings.hasCompletedOnboarding" static let startupReopenMigration = "com.TablePro.settings.didMigrateStartupToReopenLast" static let jsonFieldHeightMigration = "com.TablePro.settings.didMigrateJsonFieldHeightKey" @@ -85,6 +86,29 @@ final class AppSettingsStorage: Sendable { load(key: Keys.appearance, default: .default) } + /// Fonts moved out of the theme file, and the theme file format they lived in is no longer + /// read. Without this every user who had picked a font or zoomed the editor would silently be + /// returned to System Mono 13 on upgrade, so their sizes are lifted once from whichever theme + /// their slot pointed at, and the theme file itself is left untouched. + func loadTypography() -> TypographySettings { + if let stored = defaults.data(forKey: Keys.typography) { + do { + return try decoder.decode(TypographySettings.self, from: stored) + } catch { + Self.logger.error("Failed to decode typography settings: \(error)") + return .default + } + } + + let carried = LegacyThemeFonts.read(preferring: loadAppearance()) + saveTypography(carried) + return carried + } + + func saveTypography(_ settings: TypographySettings) { + save(settings, key: Keys.typography) + } + func saveAppearance(_ settings: AppearanceSettings) { save(settings, key: Keys.appearance) } @@ -230,6 +254,7 @@ final class AppSettingsStorage: Sendable { saveAI(.default) saveSync(.default) saveMCP(.default) + saveTypography(.default) defaults.removeObject(forKey: PreferenceKeys.selectedSettingsPane.name) defaults.removeObject(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) defaults.removeObject(forKey: PreferenceKeys.rowInspectorTextFieldHeight.name) diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index 8e73b13750..4fdb4979e6 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -8,7 +8,7 @@ import os import TableProSyncTransport @MainActor -final class FileColumnLayoutPersister: ColumnLayoutPersisting { +final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettingsStore { static let shared: FileColumnLayoutPersister = { let persister = FileColumnLayoutPersister() persister.performScopeMigration() @@ -131,20 +131,23 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { /// Persisted before either sync marker is written, because `markDeleted` posts a change /// notification that can start a sync, and a sync reading the old file would put the entry /// back under the name that has gone. - func rename(from oldKey: ColumnLayoutTableKey, to newKey: ColumnLayoutTableKey) { - var entries = loadEntries(for: oldKey.connectionId) - guard let entry = entries.removeValue(forKey: oldKey.storageKey) else { return } - entries[newKey.storageKey] = entry - cache[oldKey.connectionId] = entries - writeEntries(entries, for: oldKey.connectionId) - syncTracker.markDirty(.settings, id: Self.syncCategory(for: newKey.storageKey)) - syncTracker.markDeleted(.settings, id: Self.syncCategory(for: oldKey.storageKey)) + func renameTable(from oldScope: TableScope, to newScope: TableScope) { + let oldKey = oldScope.storageComponent + let newKey = newScope.storageComponent + guard oldKey != newKey else { return } + var entries = loadEntries(for: oldScope.connectionId) + guard let entry = entries.removeValue(forKey: oldKey) else { return } + entries[newKey] = entry + cache[oldScope.connectionId] = entries + writeEntries(entries, for: oldScope.connectionId) + syncTracker.markDirty(.settings, id: Self.syncCategory(for: newKey)) + syncTracker.markDeleted(.settings, id: Self.syncCategory(for: oldKey)) } /// Moves every table's saved layout from one container to another. Same prefix rewrite as the /// filter store, and for the same reason: the tables that have a layout are whatever the user /// has opened over the life of the connection, not what is loaded now. - func renameScope( + func renameContainer( connectionId: UUID, fromDatabase: String, fromSchema: String?, @@ -174,6 +177,16 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { } } + func purgeConnections(_ connectionIds: Set) { + var deletedCategories: [String] = [] + for connectionId in connectionIds { + deletedCategories += loadEntries(for: connectionId).keys.map(Self.syncCategory(for:)) + cache[connectionId] = [:] + removeFile(for: connectionId) + } + syncTracker.markDeleted(.settings, ids: deletedCategories) + } + func clear(for key: ColumnLayoutTableKey) { removeLegacyHidden(for: key) diff --git a/TablePro/Core/Storage/ConnectionLocalState.swift b/TablePro/Core/Storage/ConnectionLocalState.swift index 35a481283c..66387bf739 100644 --- a/TablePro/Core/Storage/ConnectionLocalState.swift +++ b/TablePro/Core/Storage/ConnectionLocalState.swift @@ -22,7 +22,8 @@ internal enum ConnectionLocalState { internal static func purge( connectionIds: Set, origin: Origin, - appSettings: AppSettingsStorage = .shared + appSettings: AppSettingsStorage = .shared, + tableScopedStores: [any TableScopedSettingsStore] = TableScopedSettingsRegistry.stores ) { guard !connectionIds.isEmpty else { return } @@ -37,7 +38,9 @@ internal enum ConnectionLocalState { QueryInsightsPreferencesStorage.remove(for: connectionId) } - FilterSettingsStorage.shared.removeFilters(for: connectionIds) + for store in tableScopedStores { + store.purgeConnections(connectionIds) + } DatabaseTreeFilterStorage.shared.removeFilters(for: connectionIds) RecentlyClosedTabStore.shared.removeEntries(for: connectionIds) WorkspaceRailOrderStore.shared.removeEntries(for: connectionIds) diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index edb873cff8..7383bc6daf 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -75,7 +75,7 @@ struct FilterSettings: Codable, Equatable { } @MainActor -final class FilterSettingsStorage { +final class FilterSettingsStorage: TableScopedSettingsStore { static let shared = FilterSettingsStorage() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "FilterSettingsStorage") @@ -84,6 +84,7 @@ final class FilterSettingsStorage { private static let migrationCompleteKey = "com.TablePro.filterStateMigrationComplete" private static let compositeKeyMigrationKey = "com.TablePro.filterStateCompositeKeyMigrationComplete" private static let settingsKey = "com.TablePro.filter.settings" + private static let browseKeySuffix = ".browse" private let defaults: UserDefaults @@ -257,38 +258,31 @@ final class FilterSettingsStorage { /// Moves a table's saved filters onto its new name. A rename keeps the columns the filters /// name, so the working set is still valid; leaving it behind would silently drop it. - func renameLastFilters( - from oldTableName: String, - to newTableName: String, - connectionId: UUID, - databaseName: String, - schemaName: String? - ) { - let oldKey = compositeKey( - tableName: oldTableName, connectionId: connectionId, - databaseName: databaseName, schemaName: schemaName - ) - let newKey = compositeKey( - tableName: newTableName, connectionId: connectionId, - databaseName: databaseName, schemaName: schemaName - ) + func renameTable(from oldScope: TableScope, to newScope: TableScope) { + let oldKey = oldScope.storageComponent + let newKey = newScope.storageComponent guard oldKey != newKey else { return } - if let cached = lastFiltersCache.removeValue(forKey: oldKey) { - lastFiltersCache[newKey] = cached + let oldBrowseKey = oldKey + Self.browseKeySuffix + let newBrowseKey = newKey + Self.browseKeySuffix + lastFiltersCache[newKey] = lastFiltersCache.removeValue(forKey: oldKey) + browseSearchCache[newBrowseKey] = browseSearchCache.removeValue(forKey: oldBrowseKey) + + let moves = [(oldKey, newKey), (oldBrowseKey, newBrowseKey)].map { source, destination in + (fileURL(forKey: source), fileURL(forKey: destination)) } - let source = fileURL(forKey: oldKey) - let destination = fileURL(forKey: newKey) ioQueue.async { - guard FileManager.default.fileExists(atPath: source.path) else { return } - try? FileManager.default.removeItem(at: destination) - try? FileManager.default.moveItem(at: source, to: destination) + let fm = FileManager.default + for (source, destination) in moves where fm.fileExists(atPath: source.path) { + try? fm.removeItem(at: destination) + try? fm.moveItem(at: source, to: destination) + } } } /// Moves every table's saved filters from one container to another, by rewriting the part of /// each key that names the container. Keyed by prefix rather than by walking the table list, /// because that list is loaded lazily and a table nobody opened this session still has a file. - func renameScope( + func renameContainer( connectionId: UUID, fromDatabase: String, fromSchema: String?, @@ -303,10 +297,8 @@ final class FilterSettingsStorage { ) guard oldPrefix != newPrefix else { return } - for key in lastFiltersCache.keys where key.hasPrefix(oldPrefix) { - let moved = newPrefix + key.dropFirst(oldPrefix.count) - lastFiltersCache[moved] = lastFiltersCache.removeValue(forKey: key) - } + lastFiltersCache = Self.rekeyed(lastFiltersCache, fromPrefix: oldPrefix, toPrefix: newPrefix) + browseSearchCache = Self.rekeyed(browseSearchCache, fromPrefix: oldPrefix, toPrefix: newPrefix) let directory = filterStateDirectory ioQueue.async { @@ -410,20 +402,13 @@ final class FilterSettingsStorage { connectionId: connectionId, databaseName: databaseName, schemaName: schemaName - ) + ".browse" - } - - func removeFilters(for connectionId: UUID) { - removeFilters(for: [connectionId]) + ) + Self.browseKeySuffix } - func removeFilters(for connectionIds: Set) { + func purgeConnections(_ connectionIds: Set) { guard !connectionIds.isEmpty else { return } - let encodedPrefixes = connectionIds.map { id in - let idString = id.uuidString - return (idString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? idString) + "." - } + let encodedPrefixes = connectionIds.map { TableScope.storagePrefix(connectionId: $0) } let matchesConnection: (String) -> Bool = { name in encodedPrefixes.contains { name.hasPrefix($0) } } @@ -445,6 +430,18 @@ final class FilterSettingsStorage { } } + private static func rekeyed( + _ cache: [String: Value], + fromPrefix oldPrefix: String, + toPrefix newPrefix: String + ) -> [String: Value] { + var rekeyed = cache + for key in cache.keys where key.hasPrefix(oldPrefix) { + rekeyed[newPrefix + key.dropFirst(oldPrefix.count)] = rekeyed.removeValue(forKey: key) + } + return rekeyed + } + private func fileURL(forKey key: String) -> URL { filterStateDirectory.appendingPathComponent("\(key).json") } diff --git a/TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift b/TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift index 90034ea964..3ef31e58d3 100644 --- a/TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift +++ b/TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift @@ -12,9 +12,11 @@ import Foundation /// setting it once for `users` is what a user means by remembering it. Device-local, so this needs /// no CloudKit record type. @MainActor -internal final class ForeignKeyLabelColumnStore { +internal final class ForeignKeyLabelColumnStore: TableScopedSettingsStore { static let shared = ForeignKeyLabelColumnStore() + private static let keyPrefix = PreferenceKeys.foreignKeyLabelColumnPrefix + private let store: KeyValueStore init(defaults: KeyValueStore = AppStorageEnvironment.shared.defaults) { @@ -36,4 +38,32 @@ internal final class ForeignKeyLabelColumnStore { } store.setDataValue(Data(name.utf8), forKey: PreferenceKeys.foreignKeyLabelColumn(scope).name) } + + func renameTable(from oldScope: TableScope, to newScope: TableScope) { + store.moveValue( + fromKey: PreferenceKeys.foreignKeyLabelColumn(oldScope).name, + toKey: PreferenceKeys.foreignKeyLabelColumn(newScope).name + ) + } + + func renameContainer( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + store.moveValues( + withPrefix: Self.keyPrefix + + TableScope.storagePrefix(connectionId: connectionId, database: fromDatabase, schema: fromSchema), + toPrefix: Self.keyPrefix + + TableScope.storagePrefix(connectionId: connectionId, database: toDatabase, schema: toSchema) + ) + } + + func purgeConnections(_ connectionIds: Set) { + for connectionId in connectionIds { + store.removeValues(withPrefix: Self.keyPrefix + TableScope.storagePrefix(connectionId: connectionId)) + } + } } diff --git a/TablePro/Core/Storage/HighlightRuleStorage.swift b/TablePro/Core/Storage/HighlightRuleStorage.swift new file mode 100644 index 0000000000..363afb9d91 --- /dev/null +++ b/TablePro/Core/Storage/HighlightRuleStorage.swift @@ -0,0 +1,184 @@ +// +// HighlightRuleStorage.swift +// TablePro +// + +import Foundation +import Observation +import os + +@MainActor +@Observable +final class HighlightRuleStorage: TableScopedSettingsStore { + static let shared = HighlightRuleStorage() + + nonisolated private static let logger = Logger( + subsystem: "com.TablePro", + category: "HighlightRuleStorage" + ) + + private(set) var revision = 0 + + @ObservationIgnored private let storageDirectory: URL + @ObservationIgnored private var cache: [UUID: [String: [HighlightRule]]] = [:] + @ObservationIgnored private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + @ObservationIgnored private let decoder = JSONDecoder() + + init(storageDirectory: URL? = nil) { + self.storageDirectory = storageDirectory ?? Self.resolvedStorageDirectory() + do { + try FileManager.default.createDirectory(at: self.storageDirectory, withIntermediateDirectories: true) + } catch { + Self.logger.error("Failed to create storage directory: \(error.localizedDescription)") + } + } + + func rules(for scope: TableScope) -> [HighlightRule] { + _ = revision + return loadEntries(for: scope.connectionId)[scope.storageComponent] ?? [] + } + + func setRules(_ rules: [HighlightRule], for scope: TableScope) { + var entries = loadEntries(for: scope.connectionId) + guard entries[scope.storageComponent, default: []] != rules else { return } + if rules.isEmpty { + entries.removeValue(forKey: scope.storageComponent) + } else { + entries[scope.storageComponent] = rules + } + commit(entries, for: scope.connectionId) + } + + func renameTable(from oldScope: TableScope, to newScope: TableScope) { + guard oldScope.storageComponent != newScope.storageComponent else { return } + var entries = loadEntries(for: oldScope.connectionId) + guard let moving = entries.removeValue(forKey: oldScope.storageComponent) else { return } + entries[newScope.storageComponent] = moving + commit(entries, for: oldScope.connectionId) + } + + func renameContainer( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + let oldPrefix = TableScope.storagePrefix(connectionId: connectionId, database: fromDatabase, schema: fromSchema) + let newPrefix = TableScope.storagePrefix(connectionId: connectionId, database: toDatabase, schema: toSchema) + guard oldPrefix != newPrefix else { return } + + var entries = loadEntries(for: connectionId) + let moving = entries.keys.filter { $0.hasPrefix(oldPrefix) } + guard !moving.isEmpty else { return } + for key in moving { + entries[newPrefix + key.dropFirst(oldPrefix.count)] = entries.removeValue(forKey: key) + } + commit(entries, for: connectionId) + } + + func purgeConnections(_ connectionIds: Set) { + guard !connectionIds.isEmpty else { return } + for connectionId in connectionIds { + cache[connectionId] = [:] + removeFile(at: fileURL(for: connectionId)) + removeFile(at: unreadableFileURL(for: connectionId)) + } + revision &+= 1 + } + + private func commit(_ entries: [String: [HighlightRule]], for connectionId: UUID) { + cache[connectionId] = entries + if entries.isEmpty { + removeFile(at: fileURL(for: connectionId)) + } else { + write(entries, for: connectionId) + } + revision &+= 1 + } + + private func loadEntries(for connectionId: UUID) -> [String: [HighlightRule]] { + if let cached = cache[connectionId] { return cached } + + let url = fileURL(for: connectionId) + guard FileManager.default.fileExists(atPath: url.path) else { + cache[connectionId] = [:] + return [:] + } + + do { + let data = try Data(contentsOf: url) + let decoded = try decoder.decode([String: [LossyHighlightRule]].self, from: data) + let entries = decoded.compactMapValues { lossy -> [HighlightRule]? in + let rules = lossy.compactMap(\.rule) + return rules.isEmpty ? nil : rules + } + cache[connectionId] = entries + return entries + } catch { + Self.logger.error( + "Unreadable highlight rules for \(connectionId, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + preserveUnreadableFile(for: connectionId) + cache[connectionId] = [:] + return [:] + } + } + + private func write(_ entries: [String: [HighlightRule]], for connectionId: UUID) { + do { + let data = try encoder.encode(entries) + try data.write(to: fileURL(for: connectionId), options: .atomic) + } catch { + Self.logger.error( + "Failed to write highlight rules for \(connectionId, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } + } + + private func preserveUnreadableFile(for connectionId: UUID) { + let preserved = unreadableFileURL(for: connectionId) + let fileManager = FileManager.default + try? fileManager.removeItem(at: preserved) + do { + try fileManager.moveItem(at: fileURL(for: connectionId), to: preserved) + } catch { + Self.logger.error("Failed to set aside unreadable highlight rules: \(error.localizedDescription, privacy: .public)") + } + } + + private func removeFile(at url: URL) { + guard FileManager.default.fileExists(atPath: url.path) else { return } + do { + try FileManager.default.removeItem(at: url) + } catch { + Self.logger.error("Failed to remove highlight rules file: \(error.localizedDescription, privacy: .public)") + } + } + + private func unreadableFileURL(for connectionId: UUID) -> URL { + storageDirectory.appendingPathComponent("\(connectionId.uuidString).unreadable.json") + } + + private func fileURL(for connectionId: UUID) -> URL { + storageDirectory.appendingPathComponent("\(connectionId.uuidString).json") + } + + private static func resolvedStorageDirectory() -> URL { + AppStorageEnvironment.shared.applicationSupportRoot + .appendingPathComponent("TablePro", isDirectory: true) + .appendingPathComponent("HighlightRules", isDirectory: true) + } +} + +private struct LossyHighlightRule: Decodable { + let rule: HighlightRule? + + init(from decoder: Decoder) throws { + rule = try? HighlightRule(from: decoder) + } +} diff --git a/TablePro/Core/Storage/Preferences/KeyValueStore.swift b/TablePro/Core/Storage/Preferences/KeyValueStore.swift index 3eae1e0000..eae43eea02 100644 --- a/TablePro/Core/Storage/Preferences/KeyValueStore.swift +++ b/TablePro/Core/Storage/Preferences/KeyValueStore.swift @@ -8,6 +8,28 @@ import Foundation protocol KeyValueStore: AnyObject, Sendable { func dataValue(forKey key: String) -> Data? func setDataValue(_ data: Data?, forKey key: String) + func keys(withPrefix prefix: String) -> [String] +} + +internal extension KeyValueStore { + func moveValue(fromKey oldKey: String, toKey newKey: String) { + guard oldKey != newKey, let data = dataValue(forKey: oldKey) else { return } + setDataValue(data, forKey: newKey) + setDataValue(nil, forKey: oldKey) + } + + func moveValues(withPrefix oldPrefix: String, toPrefix newPrefix: String) { + guard oldPrefix != newPrefix else { return } + for key in keys(withPrefix: oldPrefix) { + moveValue(fromKey: key, toKey: newPrefix + key.dropFirst(oldPrefix.count)) + } + } + + func removeValues(withPrefix prefix: String) { + for key in keys(withPrefix: prefix) { + setDataValue(nil, forKey: key) + } + } } extension UserDefaults: KeyValueStore { @@ -22,4 +44,8 @@ extension UserDefaults: KeyValueStore { } set(data, forKey: key) } + + func keys(withPrefix prefix: String) -> [String] { + dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) } + } } diff --git a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift index c338ce7eae..e5d050ede6 100644 --- a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift +++ b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift @@ -28,8 +28,11 @@ enum PreferenceKeys { lastBackupDirectory.name, ] + static let columnDisplayFormatsPrefix = "com.TablePro.columns.displayFormat." + static let foreignKeyLabelColumnPrefix = "com.TablePro.foreignKey.labelColumn." + static func columnDisplayFormats(_ scope: TableScope) -> DefaultsKey<[String: ValueDisplayFormat]> { - DefaultsKey("com.TablePro.columns.displayFormat." + scope.storageComponent) + DefaultsKey(columnDisplayFormatsPrefix + scope.storageComponent) } static func recentTables(connectionId: UUID) -> DefaultsKey<[RecentTableEntry]> { @@ -37,6 +40,6 @@ enum PreferenceKeys { } static func foreignKeyLabelColumn(_ scope: TableScope) -> DefaultsKey { - DefaultsKey("com.TablePro.foreignKey.labelColumn." + scope.storageComponent) + DefaultsKey(foreignKeyLabelColumnPrefix + scope.storageComponent) } } diff --git a/TablePro/Core/Storage/Preferences/TableScope.swift b/TablePro/Core/Storage/Preferences/TableScope.swift index 6e2560db7e..7b26992ad6 100644 --- a/TablePro/Core/Storage/Preferences/TableScope.swift +++ b/TablePro/Core/Storage/Preferences/TableScope.swift @@ -31,6 +31,10 @@ struct TableScope: Hashable, Codable, Sendable { return encode(parts) + "." } + static func storagePrefix(connectionId: UUID) -> String { + encode([connectionId.uuidString]) + "." + } + private static func encode(_ parts: [String]) -> String { parts .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 } diff --git a/TablePro/Core/Storage/TableScopedSettingsStore.swift b/TablePro/Core/Storage/TableScopedSettingsStore.swift new file mode 100644 index 0000000000..cf023905c9 --- /dev/null +++ b/TablePro/Core/Storage/TableScopedSettingsStore.swift @@ -0,0 +1,32 @@ +// +// TableScopedSettingsStore.swift +// TablePro +// + +import Foundation + +@MainActor +internal protocol TableScopedSettingsStore: AnyObject { + func renameTable(from oldScope: TableScope, to newScope: TableScope) + func renameContainer( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) + func purgeConnections(_ connectionIds: Set) +} + +@MainActor +internal enum TableScopedSettingsRegistry { + internal static var stores: [any TableScopedSettingsStore] { + [ + FilterSettingsStorage.shared, + FileColumnLayoutPersister.shared, + HighlightRuleStorage.shared, + ValueDisplayFormatStorage.shared, + ForeignKeyLabelColumnStore.shared + ] + } +} diff --git a/TablePro/Core/Storage/ValueDisplayFormatStorage.swift b/TablePro/Core/Storage/ValueDisplayFormatStorage.swift index 0ac34d9b7f..92bc9a742d 100644 --- a/TablePro/Core/Storage/ValueDisplayFormatStorage.swift +++ b/TablePro/Core/Storage/ValueDisplayFormatStorage.swift @@ -6,7 +6,7 @@ import Foundation @MainActor -internal final class ValueDisplayFormatStorage { +internal final class ValueDisplayFormatStorage: TableScopedSettingsStore { static let shared = ValueDisplayFormatStorage() private let store: KeyValueStore @@ -38,6 +38,37 @@ internal final class ValueDisplayFormatStorage { removeLegacy(for: scope) } + func renameTable(from oldScope: TableScope, to newScope: TableScope) { + let oldKey = PreferenceKeys.columnDisplayFormats(oldScope).name + if store.dataValue(forKey: oldKey) == nil { + migrateLegacy(for: oldScope) + } + store.moveValue(fromKey: oldKey, toKey: PreferenceKeys.columnDisplayFormats(newScope).name) + } + + func renameContainer( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + store.moveValues( + withPrefix: Self.keyPrefix + + TableScope.storagePrefix(connectionId: connectionId, database: fromDatabase, schema: fromSchema), + toPrefix: Self.keyPrefix + + TableScope.storagePrefix(connectionId: connectionId, database: toDatabase, schema: toSchema) + ) + } + + func purgeConnections(_ connectionIds: Set) { + for connectionId in connectionIds { + store.removeValues(withPrefix: Self.keyPrefix + TableScope.storagePrefix(connectionId: connectionId)) + store.removeValues(withPrefix: Self.legacyKeyPrefix(for: connectionId)) + } + } + + @discardableResult private func migrateLegacy(for scope: TableScope) -> [String: ValueDisplayFormat]? { let legacyKey = Self.legacyKey(for: scope) guard let data = store.dataValue(forKey: legacyKey), @@ -56,7 +87,13 @@ internal final class ValueDisplayFormatStorage { store.setDataValue(nil, forKey: Self.legacyKey(for: scope)) } + private static let keyPrefix = PreferenceKeys.columnDisplayFormatsPrefix + private static func legacyKey(for scope: TableScope) -> String { - "com.TablePro.columns.displayFormat.\(scope.connectionId.uuidString).\(scope.table)" + legacyKeyPrefix(for: scope.connectionId) + scope.table + } + + private static func legacyKeyPrefix(for connectionId: UUID) -> String { + "\(keyPrefix)\(connectionId.uuidString)." } } diff --git a/TablePro/Core/Sync/SyncChangeTracker.swift b/TablePro/Core/Sync/SyncChangeTracker.swift index 7ebf911290..c97d62e915 100644 --- a/TablePro/Core/Sync/SyncChangeTracker.swift +++ b/TablePro/Core/Sync/SyncChangeTracker.swift @@ -61,6 +61,14 @@ final class SyncChangeTracker: Sendable { postChangeNotification() } + func markDeleted(_ type: SyncRecordType, ids: [String]) { + guard !isSuppressed, !ids.isEmpty else { return } + metadataStorage.removeDirty(ids, type: type) + metadataStorage.addTombstones(ids, type: type) + Self.logger.trace("Marked deleted: \(type.rawValue) x\(ids.count)") + postChangeNotification() + } + // MARK: - Query func dirtyRecords(for type: SyncRecordType) -> Set { diff --git a/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift b/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift index ab8a162e1c..48341d9574 100644 --- a/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift +++ b/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift @@ -24,13 +24,13 @@ internal enum DisplayedResultReader { /// - Parameter selectedDisplayIndices: display positions to narrow to. Empty means every /// displayed row, which is what an untouched result set shows. - /// - Parameter deletedDisplayIndices: display positions marked for deletion but not yet saved. + /// - Parameter deletedRowIDs: rows marked for deletion but not yet saved. /// Empty, the default, reads every row. internal static func read( tableRows: TableRows, displayIDs: [RowID]?, selectedDisplayIndices: Set, - deletedDisplayIndices: Set = [], + deletedRowIDs: Set = [], columns projection: VisibleColumnProjection ) -> Output { let positions: [Int] @@ -45,7 +45,7 @@ internal enum DisplayedResultReader { guard let row = DisplayRowMapping.row( forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows ) else { return nil } - guard !deletedDisplayIndices.contains(displayIndex) else { + guard !deletedRowIDs.contains(row.id) else { skippedDeleted += 1 return nil } diff --git a/TablePro/Core/Utilities/SQL/InClauseConverter.swift b/TablePro/Core/Utilities/SQL/InClauseConverter.swift index 5967b86eb5..771d1d213b 100644 --- a/TablePro/Core/Utilities/SQL/InClauseConverter.swift +++ b/TablePro/Core/Utilities/SQL/InClauseConverter.swift @@ -11,6 +11,11 @@ internal struct InClauseConverter { internal let columnTypes: [ColumnType] internal let escapeStringLiteral: ((String) -> String)? + /// What the engine puts in front of a string literal. SQL Server's `N` is the only one, and + /// without it an `IN` list pasted into a query on a non-Unicode collation matches the rows + /// whose text was already damaged rather than the rows the user copied. + internal var stringLiteralPrefix: String = "" + private static let maxRows = 50_000 func generateInClause(rows: [[PluginCellValue]]) -> String { @@ -55,6 +60,6 @@ internal struct InClauseConverter { private func quoted(_ value: String) -> String { let escaped = escapeStringLiteral?(value) ?? value.replacingOccurrences(of: "'", with: "''") - return "'\(escaped)'" + return "\(stringLiteralPrefix)'\(escaped)'" } } diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 0b06ebbc99..76d634cd3b 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -581,6 +581,8 @@ private extension QueryClassifier { return elasticsearchClassification(trimmed) case .typesense: return typesenseClassification(trimmed) + case .weaviate: + return weaviateClassification(trimmed) default: return nil } @@ -749,4 +751,69 @@ private extension QueryClassifier { } return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: touchesUnsafeSurface) } + + /// A bare operation, a `{"query": ...}` envelope and a console body are the same request, and + /// the driver forwards the envelope verbatim, so the read-only gate has to read all three. + static func weaviateDeclaresMutation(_ body: String) -> Bool { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("mutation") { + return true + } + guard let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let query = object["query"] as? String + else { return false } + return query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("mutation") + } + + /// The driver takes a body from the rest of the request line as well as from the lines below + /// it, so the gate has to read the same two places. + static func weaviateConsoleBody(_ trimmed: String) -> String { + let lines = trimmed.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + let header = lines.first.map(String.init) ?? "" + let following = lines.count > 1 ? String(lines[1]) : "" + let parts = header.split(maxSplits: 2, omittingEmptySubsequences: true, whereSeparator: \.isWhitespace) + let inline = parts.count > 2 ? String(parts[2]) : "" + return inline.isEmpty ? following : inline + } + + static func weaviateClassification(_ trimmed: String) -> QueryClassification { + if trimmed.hasPrefix("WEAVIATE_SEARCH:") { + return .safe + } + if trimmed.hasPrefix("WEAVIATE_WRITE:") { + let encoded = String(trimmed.dropFirst("WEAVIATE_WRITE:".count)) + if let data = Data(base64Encoded: encoded), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (json["method"] as? String)?.uppercased() == "DELETE" { + return QueryClassification(tier: .destructive, reachesFilesystemOrExecutesCode: false) + } + return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + } + let lowered = trimmed.lowercased() + if lowered.hasPrefix("mutation") { + return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + } + if trimmed.hasPrefix("{") || lowered.hasPrefix("query") || lowered.hasPrefix("fragment") { + return weaviateDeclaresMutation(trimmed) + ? QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + : .safe + } + let (verb, path) = typesenseRequestLine(trimmed) + if verb == "GET" || verb == "HEAD" { + return .safe + } + if verb == "POST", path == "/V1/GRAPHQL" { + return weaviateDeclaresMutation(weaviateConsoleBody(trimmed)) + ? QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + : .safe + } + if verb == "DELETE" { + return QueryClassification(tier: .destructive, reachesFilesystemOrExecutesCode: false) + } + if verb.isEmpty { + return .safe + } + return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + } } diff --git a/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift b/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift index 9a32005289..c2f9ead605 100644 --- a/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift +++ b/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift @@ -29,20 +29,20 @@ internal enum ResultJsonSerializer { /// - Parameter selectedDisplayIndices: display positions to narrow to. Empty means every /// displayed row, which is what an untouched result set shows. - /// - Parameter deletedDisplayIndices: display positions marked for deletion but not yet saved. + /// - Parameter deletedRowIDs: rows marked for deletion but not yet saved. /// Empty, the default, serialises every row. internal static func serialize( tableRows: TableRows, displayIDs: [RowID]?, selectedDisplayIndices: Set, - deletedDisplayIndices: Set = [], + deletedRowIDs: Set = [], columns projection: VisibleColumnProjection ) -> Output { let read = DisplayedResultReader.read( tableRows: tableRows, displayIDs: displayIDs, selectedDisplayIndices: selectedDisplayIndices, - deletedDisplayIndices: deletedDisplayIndices, + deletedRowIDs: deletedRowIDs, columns: projection ) diff --git a/TablePro/Core/Utilities/SQL/SQLChunkDecoder.swift b/TablePro/Core/Utilities/SQL/SQLChunkDecoder.swift new file mode 100644 index 0000000000..672d0c8995 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/SQLChunkDecoder.swift @@ -0,0 +1,154 @@ +// +// SQLChunkDecoder.swift +// TablePro +// + +import Foundation + +/// Turns a file's bytes into text one chunk at a time, holding back whatever runs off the end of +/// a chunk until the next one arrives. +/// +/// `String(data:encoding:)` is the whole of Foundation's decoding API and it has no way to report +/// a partial character, so three measured behaviours have to be worked around rather than caught. +/// +/// A chunk of UTF-16 carrying no byte order mark decodes as big-endian. The mark is at the start +/// of the file, so only the first chunk has one, and every chunk after it in a little-endian file +/// came back byte-swapped: a UTF-16 dump over 64 KiB imported as CJK from its first chunk +/// boundary on, with nothing raised. The byte order is therefore settled once, from the mark, and +/// every chunk after that is decoded with the explicit variant. Stripping the mark becomes this +/// type's job at the same time, because only the `.utf16` spelling consumes one. +/// +/// A chunk holding an odd number of UTF-16 bytes decodes the even part and drops the last byte +/// without failing, so the alignment is held back here rather than left for the decoder to +/// notice. +/// +/// A UTF-16 surrogate pair split across a boundary fails the whole chunk, as does a partial UTF-8 +/// sequence, so a chunk that will not decode gives its last few bytes back to the next one. How +/// many is derived from the encoding rather than assumed: only UTF-8 was handled before, so a +/// character split across a boundary in any other multi-byte encoding failed the import outright. +struct SQLChunkDecoder { + private let declaredEncoding: String.Encoding + private var resolvedEncoding: String.Encoding? + private var unitSize = 1 + private var maximumTrim = 0 + private var pendingTail = Data() + + var hasPendingBytes: Bool { !pendingTail.isEmpty } + + init(encoding: String.Encoding) { + declaredEncoding = encoding + } + + mutating func decode(_ rawData: Data) -> String? { + var data = pendingTail + data.append(rawData) + pendingTail.removeAll(keepingCapacity: true) + + let encoding = resolveIfNeeded(startingWith: &data) + + var carried = Data() + let misalignment = data.count % unitSize + if misalignment > 0 { + carried = Data(data.suffix(misalignment)) + data = Data(data.prefix(data.count - misalignment)) + } + + if let decoded = String(data: data, encoding: encoding) { + pendingTail = carried + return decoded + } + + var trim = unitSize + while trim <= maximumTrim && data.count >= trim { + let head = Data(data.prefix(data.count - trim)) + if head.isEmpty { + pendingTail = data + carried + return "" + } + if let decoded = String(data: head, encoding: encoding) { + pendingTail = Data(data.suffix(trim)) + carried + return decoded + } + trim += unitSize + } + return nil + } + + private mutating func resolveIfNeeded(startingWith data: inout Data) -> String.Encoding { + if let resolvedEncoding { return resolvedEncoding } + + let resolution = Self.resolve(declaredEncoding, startingWith: data) + resolvedEncoding = resolution.encoding + unitSize = Self.unitSize(of: resolution.encoding) + maximumTrim = Self.maximumTrim(of: resolution.encoding, unitSize: unitSize) + if resolution.byteOrderMarkLength > 0, data.count >= resolution.byteOrderMarkLength { + data = Data(data.dropFirst(resolution.byteOrderMarkLength)) + } + return resolution.encoding + } + + private struct Resolution { + let encoding: String.Encoding + let byteOrderMarkLength: Int + } + + private static let utf16LittleEndianMark: [UInt8] = [0xFF, 0xFE] + private static let utf16BigEndianMark: [UInt8] = [0xFE, 0xFF] + private static let utf32LittleEndianMark: [UInt8] = [0xFF, 0xFE, 0x00, 0x00] + private static let utf32BigEndianMark: [UInt8] = [0x00, 0x00, 0xFE, 0xFF] + + /// `.utf8` is absent on purpose: Foundation consumes a UTF-8 mark itself, measured. + private static func resolve(_ encoding: String.Encoding, startingWith data: Data) -> Resolution { + switch encoding { + case .utf16: + if data.starts(with: utf16LittleEndianMark) { + return Resolution(encoding: .utf16LittleEndian, byteOrderMarkLength: 2) + } + if data.starts(with: utf16BigEndianMark) { + return Resolution(encoding: .utf16BigEndian, byteOrderMarkLength: 2) + } + return Resolution(encoding: .utf16BigEndian, byteOrderMarkLength: 0) + case .utf16LittleEndian: + return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf16LittleEndianMark) ? 2 : 0) + case .utf16BigEndian: + return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf16BigEndianMark) ? 2 : 0) + case .utf32: + if data.starts(with: utf32LittleEndianMark) { + return Resolution(encoding: .utf32LittleEndian, byteOrderMarkLength: 4) + } + if data.starts(with: utf32BigEndianMark) { + return Resolution(encoding: .utf32BigEndian, byteOrderMarkLength: 4) + } + return Resolution(encoding: .utf32BigEndian, byteOrderMarkLength: 0) + case .utf32LittleEndian: + return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf32LittleEndianMark) ? 4 : 0) + case .utf32BigEndian: + return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf32BigEndianMark) ? 4 : 0) + default: + return Resolution(encoding: encoding, byteOrderMarkLength: 0) + } + } + + private static func unitSize(of encoding: String.Encoding) -> Int { + switch encoding { + case .utf16, .utf16LittleEndian, .utf16BigEndian: + return 2 + case .utf32, .utf32LittleEndian, .utf32BigEndian: + return 4 + default: + return 1 + } + } + + /// `CFStringGetMaximumSizeForEncoding` counts bytes per UTF-16 code unit, so it is asked for + /// two of them: that is one Unicode scalar, which is the largest thing a boundary can cut in + /// half. It over-reports for several encodings, which costs a decode attempt that fails and + /// never loses a byte, since a trimmed byte goes back on the front of the next chunk. + private static func maximumTrim(of encoding: String.Encoding, unitSize: Int) -> Int { + let cfEncoding = CFStringConvertNSStringEncodingToEncoding(encoding.rawValue) + guard cfEncoding != kCFStringEncodingInvalidId else { return 0 } + let perScalar = Int(CFStringGetMaximumSizeForEncoding(2, cfEncoding)) + guard perScalar > unitSize else { return 0 } + return min(perScalar, 8) - unitSize + } +} diff --git a/TablePro/Core/Utilities/SQL/SQLFileParser.swift b/TablePro/Core/Utilities/SQL/SQLFileParser.swift index d570eae5c5..c7f9befbad 100644 --- a/TablePro/Core/Utilities/SQL/SQLFileParser.swift +++ b/TablePro/Core/Utilities/SQL/SQLFileParser.swift @@ -417,31 +417,6 @@ final class SQLFileParser: Sendable { return StepResult(advanced: true, deferred: false) } - private static func decodeChunkOrCarryTail( - rawData: Data, - pendingTail: inout Data, - encoding: String.Encoding - ) -> String? { - var data = pendingTail - data.append(rawData) - pendingTail.removeAll(keepingCapacity: true) - - if let decoded = String(data: data, encoding: encoding) { - return decoded - } - - guard encoding == .utf8 else { return nil } - - for trim in 1...3 where data.count > trim { - let head = data.prefix(data.count - trim) - if let decoded = String(data: head, encoding: .utf8) { - pendingTail = Data(data.suffix(trim)) - return decoded - } - } - return nil - } - func parseFile( url: URL, encoding: String.Encoding, @@ -463,7 +438,7 @@ final class SQLFileParser: Sendable { private var fileHandle: FileHandle? private var ctx: ParserContext private let nsBuffer = NSMutableString() - private var pendingTail = Data() + private var decoder: SQLChunkDecoder private var emitIndex = 0 private var finished = false @@ -471,6 +446,7 @@ final class SQLFileParser: Sendable { self.url = url self.encoding = encoding self.dialect = dialect + self.decoder = SQLChunkDecoder(encoding: encoding) self.ctx = ParserContext( dialect: dialect, currentStatement: countOnly ? nil : NSMutableString() @@ -515,7 +491,7 @@ final class SQLFileParser: Sendable { let handle = try openFileIfNeeded() let rawData = handle.readData(ofLength: chunkSize) - if rawData.isEmpty && pendingTail.isEmpty { + if rawData.isEmpty && !decoder.hasPendingBytes { emitTrailingStatement() finished = true closeFile() @@ -523,15 +499,13 @@ final class SQLFileParser: Sendable { } let isFinalChunk = rawData.isEmpty - guard let chunk = SQLFileParser.decodeChunkOrCarryTail( - rawData: rawData, pendingTail: &pendingTail, encoding: encoding - ) else { + guard let chunk = decoder.decode(rawData) else { throw DecompressionError.fileReadFailed( "Failed to decode file with \(encoding.description) encoding" ) } - if isFinalChunk && !pendingTail.isEmpty { + if isFinalChunk && decoder.hasPendingBytes { throw DecompressionError.fileReadFailed( "Trailing bytes did not form a valid \(encoding.description) sequence at end of file" ) diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index 2614db22a2..731938876d 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -117,7 +117,7 @@ internal struct SQLRowToStatementConverter { case .null: return "NULL" case .text(let s): - return "'\(escapeStringFn(s))'" + return "\(SQLStringLiteralPrefix.forDatabaseType(databaseType))'\(escapeStringFn(s))'" case .bytes(let data): return formatBinaryLiteral(data) } diff --git a/TablePro/Core/Utilities/SQL/SQLStringLiteralPrefix.swift b/TablePro/Core/Utilities/SQL/SQLStringLiteralPrefix.swift new file mode 100644 index 0000000000..1efadeca7b --- /dev/null +++ b/TablePro/Core/Utilities/SQL/SQLStringLiteralPrefix.swift @@ -0,0 +1,27 @@ +// +// SQLStringLiteralPrefix.swift +// TablePro +// + +import Foundation + +/// What an engine puts in front of a string literal that carries user text. +/// +/// SQL Server is the one engine that needs anything: a plain `'…'` is a `varchar` literal, so the +/// server converts it to the database collation's code page while it parses the batch, and on a +/// non-Unicode collation every character outside that page becomes `?`. That happens whatever the +/// column is, so `WHERE n = '日本語'` on an `NVARCHAR` column matches nothing the user meant. +/// `N'…'` is an `nvarchar` literal and is converted by nothing; on ASCII the two are equal. +/// +/// A number, an identifier and a `0x` binary literal never come through here. +enum SQLStringLiteralPrefix { + static func forDatabaseType(_ databaseType: DatabaseType?) -> String { + guard let databaseType else { return "" } + switch databaseType { + case .mssql: + return "N" + default: + return "" + } + } +} diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index f692b40f8f..13904534e3 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProPluginKit @MainActor final class AlertHelper { @@ -74,7 +75,7 @@ final class AlertHelper { alert.beginSheetModal(for: parent, completionHandler: completion) } - private static func run(_ alert: NSAlert, in window: NSWindow?) async -> NSApplication.ModalResponse { + static func response(to alert: NSAlert, in window: NSWindow?) async -> NSApplication.ModalResponse { await withCheckedContinuation { continuation in present(alert, in: window) { continuation.resume(returning: $0) } } @@ -97,7 +98,7 @@ final class AlertHelper { alert.alertStyle = .informational alert.addButton(withTitle: confirmButton) Self.addCancelButton(to: alert, title: cancelButton) - return await run(alert, in: window) == .alertFirstButtonReturn + return await response(to: alert, in: window) == .alertFirstButtonReturn } // MARK: - Destructive Confirmations @@ -114,7 +115,7 @@ final class AlertHelper { alert.informativeText = message alert.alertStyle = .warning Self.addConfirmAndCancel(to: alert, confirmButton: confirmButton, cancelButton: cancelButton) - return await run(alert, in: window) == .alertFirstButtonReturn + return await response(to: alert, in: window) == .alertFirstButtonReturn } // MARK: - Critical Confirmations @@ -131,7 +132,7 @@ final class AlertHelper { alert.informativeText = message alert.alertStyle = .critical Self.addConfirmAndCancel(to: alert, confirmButton: confirmButton, cancelButton: cancelButton) - return await run(alert, in: window) == .alertFirstButtonReturn + return await response(to: alert, in: window) == .alertFirstButtonReturn } // MARK: - Cross-Process Approval @@ -155,37 +156,108 @@ final class AlertHelper { } /// Pairing is a security decision, so the attached case uses a critical sheet: it must not - /// queue behind whatever sheet the window is already showing. The detached case runs the modal - /// loop directly rather than inside a continuation-installing closure, which would block the - /// main actor while the continuation is still being installed. + /// queue behind whatever sheet the window is already showing. static func runPairingApproval(request: PairingRequest) async throws -> PairingApproval { let codeExpiresAt = Date.now.addingTimeInterval(PairingExchangeStore.exchangeWindow) - let gate = PairingApprovalGate() - let host = NSHostingController( + let gate = ModalDecisionGate.pairing() + return try await runHostedDecision( + title: String(localized: "Approve Integration"), + fittingWidth: 520, + gate: gate, + window: nil, rootView: PairingApprovalSheet( request: request, codeExpiresAt: codeExpiresAt, onComplete: { result in gate.deliver(result) } ) ) + } + + /// A statement the user has to read before approving it. The alert vocabulary cannot carry one: + /// `informativeText` is a proportional label with no scrolling and no selection, so a statement + /// long enough to be worth reviewing is exactly the one it cannot show. + /// + /// The windowless path is the ordinary case rather than a fallback. A request arriving over MCP + /// reaches a Mac whose TablePro may have no window open at all, and that is precisely when this + /// dialog is the only place the statement is visible. + static func runStatementConfirmation( + title: String, + subtitle: String, + warning: String?, + statements: [String], + databaseType: DatabaseType, + confirmTitle: String, + isDestructive: Bool, + window: NSWindow? + ) async -> Bool { + let gate = ModalDecisionGate.confirmation() + let presented = Binding( + get: { true }, + set: { isPresented in + guard !isPresented else { return } + gate.deliver(.success(false)) + } + ) + let sheet = SQLReviewSheet( + isPresented: presented, + statements: statements, + databaseType: databaseType, + title: title, + subtitle: subtitle, + showsStatementsVerbatim: true, + warning: warning, + primaryAction: SQLReviewSheet.PrimaryAction( + title: confirmTitle, + isDestructive: isDestructive, + takesDefaultAction: false, + work: .immediate { gate.deliver(.success(true)) } + ) + ) + let confirmed = try? await runHostedDecision( + title: title, + fittingWidth: 560, + gate: gate, + window: window, + rootView: sheet + ) + return confirmed ?? false + } + + /// The one presentation path for a SwiftUI decision the user must answer: a critical sheet on + /// the window they were working in, and an application-modal window when none qualifies. The + /// modal loop runs directly rather than inside a continuation-installing closure, which would + /// block the main actor while the continuation is still being installed. + private static func runHostedDecision( + title: String, + fittingWidth: CGFloat, + gate: ModalDecisionGate, + window: NSWindow?, + rootView: some View + ) async throws -> Value { + let host = NSHostingController(rootView: rootView) host.sizingOptions = [] - let fitted = host.sizeThatFits(in: NSSize(width: 520, height: CGFloat.greatestFiniteMagnitude)) + let fitted = host.sizeThatFits(in: NSSize(width: fittingWidth, height: CGFloat.greatestFiniteMagnitude)) host.view.frame = NSRect(origin: .zero, size: fitted) - let sheetWindow = NSWindow.titled(String(localized: "Approve Integration"), contentViewController: host) + host.title = title + let sheetWindow = ModalDecisionWindow(contentViewController: host) sheetWindow.styleMask = [.titled, .closable] sheetWindow.isReleasedWhenClosed = false + /// The HIG gives Escape to Cancel on every alert and sheet, and a confirmation that came + /// forward over the user's own work is exactly the one that has to stay dismissable. + sheetWindow.onCancel = { [weak gate] in gate?.cancel() } - guard let parent = resolveWindow(nil) else { - let delegate = PairingApprovalWindowDelegate(gate: gate) - sheetWindow.delegate = delegate + let delegate = ModalDecisionWindowDelegate { [weak gate] in gate?.cancel() } + gate.windowDelegate = delegate + sheetWindow.delegate = delegate + + guard let parent = resolveWindow(window) else { gate.onResolve = { [weak sheetWindow] in NSApp.stopModal() sheetWindow?.close() } AppActivationPolicyController.shared.activate(ignoringOtherApps: true) sheetWindow.center() - defer { withExtendedLifetime(delegate) {} } NSApp.runModal(for: sheetWindow) AppActivationPolicyController.shared.reevaluate(excluding: sheetWindow) return try gate.result() @@ -194,6 +266,7 @@ final class AlertHelper { gate.onResolve = { [weak sheetWindow] in guard let sheetWindow else { return } parent.endSheet(sheetWindow) + sheetWindow.close() } parent.beginCriticalSheet(sheetWindow, completionHandler: nil) return try await gate.value() @@ -222,7 +295,7 @@ final class AlertHelper { dontSaveButton.keyEquivalent = "d" dontSaveButton.keyEquivalentModifierMask = .command - switch await run(alert, in: window) { + switch await response(to: alert, in: window) { case .alertFirstButtonReturn: return .save case .alertThirdButtonReturn: return .dontSave default: return .cancel @@ -247,7 +320,7 @@ final class AlertHelper { alert.addButton(withTitle: second) alert.addButton(withTitle: third) - switch await run(alert, in: window) { + switch await response(to: alert, in: window) { case .alertFirstButtonReturn: return 0 case .alertSecondButtonReturn: return 1 case .alertThirdButtonReturn: return 2 diff --git a/TablePro/Core/Utilities/UI/ModalDecisionGate.swift b/TablePro/Core/Utilities/UI/ModalDecisionGate.swift new file mode 100644 index 0000000000..39c3052eec --- /dev/null +++ b/TablePro/Core/Utilities/UI/ModalDecisionGate.swift @@ -0,0 +1,96 @@ +// +// ModalDecisionGate.swift +// TablePro +// + +import AppKit + +/// A hosted modal can be answered twice: the view's own button and the window closing both arrive. +/// Only the first counts, or a continuation resumes more than once and the process traps. +/// +/// `cancellationOutcome` is what closing the window means to this particular modal. Pairing treats +/// it as a thrown cancellation; a statement confirmation treats it as an ordinary no. +@MainActor +internal final class ModalDecisionGate { + internal var onResolve: (() -> Void)? + + /// `NSWindow.delegate` is weak and the sheet path returns while the sheet is still up, so the + /// delegate that reports a close has to be owned by something whose lifetime matches the modal. + internal var windowDelegate: (any NSWindowDelegate)? + + private let cancellationOutcome: Result + private var outcome: Result? + private var waiter: CheckedContinuation? + + internal init(cancellationOutcome: Result) { + self.cancellationOutcome = cancellationOutcome + } + + internal func deliver(_ result: Result) { + guard outcome == nil else { return } + outcome = result + onResolve?() + guard let waiter else { return } + self.waiter = nil + waiter.resume(with: result) + } + + internal func cancel() { + deliver(cancellationOutcome) + } + + internal func value() async throws -> Value { + if let outcome { return try outcome.get() } + return try await withCheckedThrowingContinuation { continuation in + waiter = continuation + } + } + + internal func result() throws -> Value { + guard let outcome else { return try cancellationOutcome.get() } + return try outcome.get() + } +} + +internal extension ModalDecisionGate where Value == PairingApproval { + static func pairing() -> ModalDecisionGate { + ModalDecisionGate(cancellationOutcome: .failure(DatabaseAccessError.userCancelled)) + } +} + +internal extension ModalDecisionGate where Value == Bool { + static func confirmation() -> ModalDecisionGate { + ModalDecisionGate(cancellationOutcome: .success(false)) + } +} + +/// The window a hosted decision lives in, so Escape has somewhere to land. +/// +/// A local `.keyDown` monitor cannot serve: measured on macOS 26.6, one installed over this sheet +/// reports Tab and every letter and never reports key code 53, because AppKit turns Escape into +/// `cancelOperation(_:)` and sends it up the responder chain first. Inside a hosted SwiftUI view +/// neither `.onExitCommand` nor a `.cancelAction` button answers it, and the window is the end of +/// that chain. +@MainActor +internal final class ModalDecisionWindow: NSWindow { + internal var onCancel: (() -> Void)? + + override internal func cancelOperation(_ sender: Any?) { + onCancel?() + } +} + +/// Closing the window is an answer, so it has to reach the gate rather than leave the caller +/// waiting on a modal that is no longer on screen. +@MainActor +internal final class ModalDecisionWindowDelegate: NSObject, NSWindowDelegate { + private let onClose: () -> Void + + internal init(onClose: @escaping () -> Void) { + self.onClose = onClose + } + + internal func windowWillClose(_ notification: Notification) { + onClose() + } +} diff --git a/TablePro/Core/Utilities/UI/PairingApprovalGate.swift b/TablePro/Core/Utilities/UI/PairingApprovalGate.swift deleted file mode 100644 index cb90e9db10..0000000000 --- a/TablePro/Core/Utilities/UI/PairingApprovalGate.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// PairingApprovalGate.swift -// TablePro -// - -import AppKit - -/// A pairing prompt can be answered twice: the sheet's own button and the window closing both -/// arrive. Only the first counts, or a continuation resumes more than once and the process traps. -@MainActor -internal final class PairingApprovalGate { - internal var onResolve: (() -> Void)? - - private var outcome: Result? - private var waiter: CheckedContinuation? - - internal func deliver(_ result: Result) { - guard outcome == nil else { return } - outcome = result - onResolve?() - guard let waiter else { return } - self.waiter = nil - waiter.resume(with: result) - } - - internal func cancel() { - deliver(.failure(DatabaseAccessError.userCancelled)) - } - - internal func value() async throws -> PairingApproval { - if let outcome { return try outcome.get() } - return try await withCheckedThrowingContinuation { continuation in - waiter = continuation - } - } - - internal func result() throws -> PairingApproval { - guard let outcome else { throw DatabaseAccessError.userCancelled } - return try outcome.get() - } -} - -@MainActor -internal final class PairingApprovalWindowDelegate: NSObject, NSWindowDelegate { - private let gate: PairingApprovalGate - - internal init(gate: PairingApprovalGate) { - self.gate = gate - } - - internal func windowWillClose(_ notification: Notification) { - gate.cancel() - } -} diff --git a/TablePro/Core/Vim/VimCursorManager.swift b/TablePro/Core/Vim/VimCursorManager.swift index 37324754c5..a03c9b0df6 100644 --- a/TablePro/Core/Vim/VimCursorManager.swift +++ b/TablePro/Core/Vim/VimCursorManager.swift @@ -154,7 +154,7 @@ final class VimCursorManager { } else { let layer = CALayer() layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 - layer.backgroundColor = ThemeEngine.shared.colors.editor.cursor.withAlphaComponent(0.4).cgColor + layer.backgroundColor = ThemeEngine.shared.resolved[.editorCursor].withAlphaComponent(0.4).cgColor layer.frame = frame if !isPaused { diff --git a/TablePro/Core/Vim/VimTextBufferAdapter.swift b/TablePro/Core/Vim/VimTextBufferAdapter.swift index 5747c5e914..acbf3e4934 100644 --- a/TablePro/Core/Vim/VimTextBufferAdapter.swift +++ b/TablePro/Core/Vim/VimTextBufferAdapter.swift @@ -414,7 +414,7 @@ final class VimTextBufferAdapter: VimTextBuffer { } func indentWidth() -> Int { - ThemeEngine.shared.tabWidth + AppSettingsManager.shared.editor.clampedTabWidth } // MARK: - Helpers diff --git a/TablePro/Info.plist b/TablePro/Info.plist index cf67a7aafc..40bd760bdc 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -415,6 +415,7 @@ mysql mariadb tidb + oceanbase sqlite mongodb redis diff --git a/TablePro/Models/Connection/DatabaseType.swift b/TablePro/Models/Connection/DatabaseType.swift index db70ba1768..1d2901a060 100644 --- a/TablePro/Models/Connection/DatabaseType.swift +++ b/TablePro/Models/Connection/DatabaseType.swift @@ -18,6 +18,7 @@ extension DatabaseType { static let mariadb = DatabaseType(rawValue: "MariaDB") static let tidb = DatabaseType(rawValue: "TiDB") static let databend = DatabaseType(rawValue: "Databend") + static let oceanbase = DatabaseType(rawValue: "OceanBase") static let postgresql = DatabaseType(rawValue: "PostgreSQL") static let sqlite = DatabaseType(rawValue: "SQLite") static let redshift = DatabaseType(rawValue: "Redshift") @@ -49,6 +50,7 @@ extension DatabaseType { static let typesense = DatabaseType(rawValue: "Typesense") static let teradata = DatabaseType(rawValue: "Teradata") static let trino = DatabaseType(rawValue: "Trino") + static let weaviate = DatabaseType(rawValue: "Weaviate") } extension DatabaseType: Codable { diff --git a/TablePro/Models/Database/DatabaseObjectTarget.swift b/TablePro/Models/Database/DatabaseObjectTarget.swift new file mode 100644 index 0000000000..10584eb011 --- /dev/null +++ b/TablePro/Models/Database/DatabaseObjectTarget.swift @@ -0,0 +1,28 @@ +// +// DatabaseObjectTarget.swift +// TablePro +// + +import Foundation + +/// One table-like object a command acts on, with the scope its statement runs in. +/// +/// The scope comes from the object's own database and schema, never from where the sidebar or the +/// selected tab happens to point, so a command raised on a view in another schema cannot act on a +/// same-named view in the browsed one. +struct DatabaseObjectTarget: Equatable, Sendable { + let name: String + let type: TableInfo.TableType + let schema: String? + let scope: DatabaseScope + + /// How the object is named to the user: its schema and its name, as the sidebar shows them. + var qualifiedName: String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } + + func change(_ kind: DatabaseObjectChange.Kind) -> DatabaseObjectChange { + DatabaseObjectChange(connectionId: scope.connectionId, scope: scope, name: name, kind: kind) + } +} diff --git a/TablePro/Models/Database/MaterializedViewRefreshPrompt.swift b/TablePro/Models/Database/MaterializedViewRefreshPrompt.swift new file mode 100644 index 0000000000..3bb3c1f253 --- /dev/null +++ b/TablePro/Models/Database/MaterializedViewRefreshPrompt.swift @@ -0,0 +1,89 @@ +// +// MaterializedViewRefreshPrompt.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// What the Refresh Materialized View confirmation says, kept apart from the alert so it can be +/// asserted without presenting one. +/// +/// The option to refresh without blocking readers is shown only where the engine has one, and it is +/// enabled only for a view the server will accept it for. Offering it on engine version alone is how +/// other clients ended up with a checkbox the server then refuses. +internal struct MaterializedViewRefreshPrompt: Equatable { + internal let qualifiedName: String + /// Nil when the engine has no concurrent refresh at all. + internal let availability: PluginConcurrentRefreshAvailability? + /// The check failed, so whether the view qualifies is not known. The option is shown disabled + /// rather than left out, so the user is not told the engine lacks it. + internal let availabilityCheckFailed: Bool + + internal init( + qualifiedName: String, + availability: PluginConcurrentRefreshAvailability?, + availabilityCheckFailed: Bool = false + ) { + self.qualifiedName = qualifiedName + self.availability = availability + self.availabilityCheckFailed = availabilityCheckFailed + } + + internal var messageText: String { + String(format: String(localized: "Refresh the materialized view “%@”?"), qualifiedName) + } + + internal var informativeText: String { + String( + localized: """ + The view's query runs again and replaces its stored rows. A plain refresh stops other \ + sessions from reading the view until it finishes. + """ + ) + } + + internal var confirmButtonTitle: String { + String(localized: "Refresh") + } + + internal var cancelButtonTitle: String { + String(localized: "Cancel") + } + + internal var showsConcurrentOption: Bool { + availability != nil || availabilityCheckFailed + } + + internal var isConcurrentOptionEnabled: Bool { + availability == .available + } + + internal var concurrentOptionTitle: String { + String(localized: "Refresh concurrently") + } + + internal var concurrentOptionDescription: String { + if availabilityCheckFailed { + return String(localized: "Couldn't check whether this view can be refreshed concurrently.") + } + switch availability { + case .available: + return String(localized: "Other sessions keep reading the view. Slower, because the new rows are compared with the old ones.") + case .requiresUniqueIndex: + return String(localized: "Needs a valid unique index on the view's columns, with no WHERE clause and no expressions.") + case .requiresPopulatedView: + return String(localized: "Available once the view holds rows. Refresh it once without this option first.") + case .none: + return "" + @unknown default: + return String(localized: "This view can't be refreshed concurrently.") + } + } + + /// Only honoured while the option is enabled, so a stale checkbox can never ask the server for + /// a refresh it has already said it would refuse. + internal func refreshesConcurrently(checkboxIsOn: Bool) -> Bool { + isConcurrentOptionEnabled && checkboxIsOn + } +} diff --git a/TablePro/Models/Database/ObjectCommentDraft.swift b/TablePro/Models/Database/ObjectCommentDraft.swift new file mode 100644 index 0000000000..8602e56907 --- /dev/null +++ b/TablePro/Models/Database/ObjectCommentDraft.swift @@ -0,0 +1,39 @@ +// +// ObjectCommentDraft.swift +// TablePro +// + +import Foundation + +/// The text in the Edit Comment sheet and what saving it would write. +/// +/// A comment that is empty or only whitespace is removed rather than stored, which is what an empty +/// field means to the person clearing it and what PostgreSQL itself does with an empty string. +internal struct ObjectCommentDraft: Equatable { + internal let original: String? + internal var text: String + + internal init(original: String?) { + let normalized = Self.normalized(original) + self.original = normalized + self.text = normalized ?? "" + } + + /// Nil removes the comment. + internal var commentToSave: String? { + Self.normalized(text) + } + + internal var hasChanges: Bool { + commentToSave != original + } + + internal var removesComment: Bool { + original != nil && commentToSave == nil + } + + private static func normalized(_ comment: String?) -> String? { + guard let comment, !comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return comment + } +} diff --git a/TablePro/Models/Database/StructureEditEligibility.swift b/TablePro/Models/Database/StructureEditEligibility.swift new file mode 100644 index 0000000000..578b370953 --- /dev/null +++ b/TablePro/Models/Database/StructureEditEligibility.swift @@ -0,0 +1,268 @@ +// +// StructureEditEligibility.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// One change the Structure tab can ask a server to make. +/// +/// The vocabulary is per operation rather than per object because the server answers per operation. +/// Measured on PostgreSQL 17.11: a view takes `RENAME COLUMN`, `SET DEFAULT`, `DROP DEFAULT` and +/// `COMMENT ON COLUMN` and refuses `ADD COLUMN`, `SET NOT NULL`, `SET DATA TYPE`, `CREATE INDEX` and +/// every `ADD CONSTRAINT`, while a materialized view takes `CREATE INDEX` and refuses `SET DEFAULT`. +/// A single read-only flag for the whole object would withhold four edits that work and still offer +/// several the server always refuses. +enum StructureEditOperation: Sendable, Hashable, CaseIterable { + case addColumn + case dropColumn + case renameColumn + case setNotNull + case dropNotNull + case setDefault + case dropDefault + case changeColumnType + /// The column attributes an engine can only change by rewriting the whole column definition: + /// primary key, auto increment, on update, charset, collation and generated. One case rather + /// than six, so none of them is ever gated through a proxy that means something else. + case redefineColumn + case addIndex + case dropIndex + case addForeignKey + case dropForeignKey + case addCheckConstraint + case dropCheckConstraint + case commentOnColumn + case reorderColumns + + /// What the edit is about, which is what a refusal has to explain. A materialized view refuses + /// `SET DEFAULT` and `ADD CONSTRAINT FOREIGN KEY` for two different reasons, and one sentence + /// covering both would be true of neither. + enum Subject: Sendable, Hashable { + case columns + case columnOrder + case indexes + case constraints + } + + var subject: Subject { + switch self { + case .addColumn, .dropColumn, .renameColumn, .setNotNull, .dropNotNull, .setDefault, + .dropDefault, .changeColumnType, .redefineColumn, .commentOnColumn: + return .columns + case .reorderColumns: + return .columnOrder + case .addIndex, .dropIndex: + return .indexes + case .addForeignKey, .dropForeignKey, .addCheckConstraint, .dropCheckConstraint: + return .constraints + } + } +} + +/// Which structure edits each kind of object accepts on one engine. +/// +/// Curated per `DatabaseType` in `SchemaEditingSupport` rather than asked of a driver, because the +/// affordance has to be offered or withheld before the user starts filling a row in, and a +/// capability the app only learns from a failed statement is far too late for that. +struct StructureObjectEditMatrix: Sendable, Equatable { + private let operationsByKind: [TableInfo.TableType: Set] + + init(_ operationsByKind: [TableInfo.TableType: Set]) { + self.operationsByKind = operationsByKind + } + + func operations(for kind: TableInfo.TableType) -> Set { + operationsByKind[kind] ?? [] + } + + private static let everyOperation = Set(StructureEditOperation.allCases) + + /// The default for an engine nobody has curated: a real table takes every edit the engine's own + /// flags allow, and no other kind takes any. Conservative on purpose. Offering an edit the + /// server refuses costs the user a filled-in row and an error at Save; withholding one it would + /// have taken costs a trip to the query editor. + static let tablesOnly = StructureObjectEditMatrix([ + .table: everyOperation, + .partitionedTable: everyOperation + ]) + + /// Measured against PostgreSQL 17.11, one statement per cell. A table and a partitioned table + /// take all seventeen. A view and a materialized view differ on `SET DEFAULT` and on + /// `CREATE INDEX`, which is why one row per kind is the only shape that works. A foreign table + /// takes every column change and `ADD CHECK` but refuses `CREATE INDEX`, `FOREIGN KEY` and + /// `UNIQUE`. A system table refuses everything with "permission denied: is a system catalog", + /// and an external table is a Redshift Spectrum relation whose columns live in another catalog. + static let postgreSQL = StructureObjectEditMatrix([ + .table: everyOperation, + .partitionedTable: everyOperation, + .view: [.renameColumn, .setDefault, .dropDefault, .commentOnColumn], + .materializedView: [.renameColumn, .commentOnColumn, .addIndex, .dropIndex], + .foreignTable: [ + .addColumn, .dropColumn, .renameColumn, .setNotNull, .dropNotNull, .setDefault, + .dropDefault, .changeColumnType, .addCheckConstraint, .dropCheckConstraint, + .commentOnColumn + ], + .systemTable: [], + .externalTable: [] + ]) +} + +/// Whether the Structure tab may offer one edit right now, and what to say when it may not. +/// +/// Mirrors `ForeignKeyEditAvailability` so every refusal in this tab carries its own sentence. A +/// control that dims without saying why reads as a broken app, and a control that stays enabled over +/// a statement the server always refuses is worse: the user fills the row in first. +enum StructureEditAvailability: Sendable, Equatable { + case available + + case unavailable(reason: String) + + var isAvailable: Bool { self == .available } + + var unavailableReason: String? { + if case .unavailable(let reason) = self { return reason } + return nil + } +} + +/// The single decision about which structure edits an object's kind accepts. +/// +/// Pure, so the rule is testable without a connection, and ordered: an engine that cannot edit +/// structure at all says that first, then the object's kind, then the engine's own statement for the +/// operation. Reading the kind as one `isView` Bool is the defect this replaces, because +/// `TableInfo.TableType.allowsRowEditing` is true for a materialized view, so the Structure tab +/// offered `ADD COLUMN`, `SET NOT NULL`, type changes and constraint edits that PostgreSQL always +/// refuses. (#2726) +enum StructureEditEligibility { + static func allows( + _ operation: StructureEditOperation, + on kind: TableInfo.TableType, + matrix: StructureObjectEditMatrix + ) -> Bool { + matrix.operations(for: kind).contains(operation) + } + + /// Why this kind of object refuses the operation, and nil when it does not. + /// + /// Handed to `ForeignKeyEditPolicy` and `ColumnReorderPolicy`, which used to decide it from an + /// `isTable` Bool and then name a view in the sentence. Only the matrix knows which of seven + /// kinds is in front of the user, so only the matrix can word the refusal. + static func refusalReason( + for operation: StructureEditOperation, + on kind: TableInfo.TableType, + matrix: StructureObjectEditMatrix + ) -> String? { + guard !allows(operation, on: kind, matrix: matrix) else { return nil } + return reason(operation.subject, kind) + } + + static func resolve( + _ operation: StructureEditOperation, + on kind: TableInfo.TableType, + matrix: StructureObjectEditMatrix, + engineAllows: Bool, + engineName: String, + canEditSchema: Bool + ) -> StructureEditAvailability { + guard canEditSchema else { + return .unavailable( + reason: String(format: String(localized: "%@ cannot edit a table's structure."), engineName) + ) + } + if let refusal = refusalReason(for: operation, on: kind, matrix: matrix) { + return .unavailable(reason: refusal) + } + guard engineAllows else { + return .unavailable(reason: engineReason(operation.subject, engineName)) + } + return .available + } + + /// Every field of the Columns grid this kind of object lets the user change. + /// + /// Keyed by field rather than by row, because the refusal is per column attribute: PostgreSQL + /// takes a view's `RENAME COLUMN` and `SET DEFAULT` and refuses its `SET NOT NULL` and + /// `SET DATA TYPE`, so Name and Default stay editable while Nullable and Type lock. + static func editableFields( + on kind: TableInfo.TableType, + matrix: StructureObjectEditMatrix + ) -> Set { + let allowed = matrix.operations(for: kind) + return Set(StructureColumnField.allCases.filter { field in + !operations(expressing: field).isDisjoint(with: allowed) + }) + } + + static func allowsAnyEdit(on kind: TableInfo.TableType, matrix: StructureObjectEditMatrix) -> Bool { + !matrix.operations(for: kind).isEmpty + } + + /// The operations that can express a change to this field. A field with more than one is + /// editable as soon as either statement is accepted, because the grid cannot know in advance + /// which direction the user will toggle it. + private static func operations(expressing field: StructureColumnField) -> Set { + switch field { + case .name: return [.renameColumn] + case .type: return [.changeColumnType] + case .nullable: return [.setNotNull, .dropNotNull] + case .defaultValue: return [.setDefault, .dropDefault] + case .comment: return [.commentOnColumn] + case .onUpdate, .primaryKey, .autoIncrement, .charset, .collation, .generated, + .generationExpression: + return [.redefineColumn] + @unknown default: + /// A field this build does not know is gated behind a full column rewrite, which is the + /// narrowest answer available and therefore the safe one. + return [.redefineColumn] + } + } + + /// Why the engine cannot make the change even on a plain table. Worded per subject rather than + /// once, because "cannot edit a table's structure" over a dimmed **Add Index** tells the reader + /// nothing they could act on. + private static func engineReason(_ subject: StructureEditOperation.Subject, _ engineName: String) -> String { + switch subject { + case .columns: + return String(format: String(localized: "%@ cannot make this change to a table's columns."), engineName) + case .columnOrder: + return String( + format: String(localized: "%@ cannot change the order of a table's columns."), + engineName + ) + case .indexes: + return String(format: String(localized: "%@ cannot add or remove a table's indexes."), engineName) + case .constraints: + return String(format: String(localized: "%@ cannot add or remove a table's constraints."), engineName) + } + } + + private static func reason(_ subject: StructureEditOperation.Subject, _ kind: TableInfo.TableType) -> String { + let noun = objectNoun(kind) + switch subject { + case .columns: + return String(format: String(localized: "%@ does not accept this column change."), noun) + case .columnOrder: + return String(format: String(localized: "%@ has no column order of its own to change."), noun) + case .indexes: + return String(format: String(localized: "%@ cannot have indexes."), noun) + case .constraints: + return String(format: String(localized: "%@ cannot have constraints."), noun) + } + } + + /// The object's kind as the subject of a sentence, because every refusal above names it. The + /// sidebar's own label is a bare noun ("View"), which reads as a heading rather than a sentence. + private static func objectNoun(_ kind: TableInfo.TableType) -> String { + switch kind { + case .table: return String(localized: "A table") + case .partitionedTable: return String(localized: "A partitioned table") + case .view: return String(localized: "A view") + case .materializedView: return String(localized: "A materialized view") + case .foreignTable: return String(localized: "A foreign table") + case .systemTable: return String(localized: "A system table") + case .externalTable: return String(localized: "An external table") + } + } +} diff --git a/TablePro/Models/Database/TableOperationEligibility.swift b/TablePro/Models/Database/TableOperationEligibility.swift index ecd54f1305..e4385403c3 100644 --- a/TablePro/Models/Database/TableOperationEligibility.swift +++ b/TablePro/Models/Database/TableOperationEligibility.swift @@ -36,4 +36,36 @@ enum TableOperationEligibility { guard !targets.isEmpty else { return false } return targets.allSatisfy { canTruncate($0.table.type) } } + + /// The driver's vocabulary for the same kind. Spelled out rather than taken from `rawValue` so + /// adding a case to `TableInfo.TableType` stops compiling here instead of silently producing a + /// kind no driver declares, which would read as "no maintenance applies". + static func pluginKind(_ type: TableInfo.TableType?) -> PluginObjectKind { + switch type { + case .table, .none: return .table + case .partitionedTable: return .partitionedTable + case .view: return .view + case .materializedView: return .materializedView + case .foreignTable: return .foreignTable + case .systemTable: return .systemTable + case .externalTable: return .externalTable + } + } + + /// The maintenance the driver offers on an object of this kind. + /// + /// The one answer for the sidebar's contextual menu, the menu bar's Table Maintenance submenu and + /// the MCP tool. Offering every operation on every row is how `VACUUM` came to be offered on a + /// view, where PostgreSQL skips it with a WARNING and still reports success, and `REINDEX` on one, + /// where it fails outright. + /// + /// An operation whose statement names no object is kept whatever the row is: the row is only + /// where it is reached from, which is how SQLite's `VACUUM` is reachable at all. + static func maintenanceOperations( + _ all: [PluginMaintenanceOperation], + for type: TableInfo.TableType? + ) -> [PluginMaintenanceOperation] { + let kind = pluginKind(type) + return all.filter { $0.scope.admitsObject ? $0.applies(to: kind) : true } + } } diff --git a/TablePro/Models/Export/ImportModels.swift b/TablePro/Models/Export/ImportModels.swift index 9e622d5867..c1fd9b4f38 100644 --- a/TablePro/Models/Export/ImportModels.swift +++ b/TablePro/Models/Export/ImportModels.swift @@ -9,25 +9,45 @@ import Foundation // MARK: - Import Encoding Options -/// Available text encodings for import +/// The text encodings the SQL import dialog offers, matching the list the CSV inspector already +/// offers in `CSVPropertyOptions.encodings`. +/// +/// The raw value is the key the last choice is stored under, so it stays put; `label` is what the +/// menu shows. Latin-1 and Windows-1252 are both here because they disagree over 0x80 to 0x9F, +/// which is where a dump written by MySQL keeps its curly quotes, en dashes and euro sign: read +/// as Latin-1 they arrive as C1 control characters instead. enum ImportEncoding: String, CaseIterable, Identifiable { case utf8 = "UTF-8" case utf16 = "UTF-16" + case utf16LittleEndian = "UTF-16LE" + case utf16BigEndian = "UTF-16BE" case latin1 = "Latin1" + case windows1252 = "Windows-1252" case ascii = "ASCII" var id: String { rawValue } + var label: String { + switch self { + case .utf8: return "UTF-8" + case .utf16: return "UTF-16" + case .utf16LittleEndian: return "UTF-16 LE" + case .utf16BigEndian: return "UTF-16 BE" + case .latin1: return "Latin-1" + case .windows1252: return "Windows-1252" + case .ascii: return "ASCII" + } + } + var encoding: String.Encoding { switch self { - case .utf8: - return .utf8 - case .utf16: - return .utf16 - case .latin1: - return .isoLatin1 - case .ascii: - return .ascii + case .utf8: return .utf8 + case .utf16: return .utf16 + case .utf16LittleEndian: return .utf16LittleEndian + case .utf16BigEndian: return .utf16BigEndian + case .latin1: return .isoLatin1 + case .windows1252: return .windowsCP1252 + case .ascii: return .ascii } } } diff --git a/TablePro/Models/Highlight/HighlightRule.swift b/TablePro/Models/Highlight/HighlightRule.swift new file mode 100644 index 0000000000..9dcec164c7 --- /dev/null +++ b/TablePro/Models/Highlight/HighlightRule.swift @@ -0,0 +1,139 @@ +// +// HighlightRule.swift +// TablePro +// + +import Foundation + +enum HighlightColor: String, CaseIterable, Identifiable, Codable, Sendable { + case red + case orange + case yellow + case green + case blue + case purple + case gray + + var id: String { rawValue } + + var displayName: String { + switch self { + case .red: return String(localized: "Red") + case .orange: return String(localized: "Orange") + case .yellow: return String(localized: "Yellow") + case .green: return String(localized: "Green") + case .blue: return String(localized: "Blue") + case .purple: return String(localized: "Purple") + case .gray: return String(localized: "Gray") + } + } +} + +enum HighlightTarget: String, CaseIterable, Identifiable, Codable, Sendable { + case row + case cell + + var id: String { rawValue } + + var displayName: String { + switch self { + case .row: return String(localized: "Row") + case .cell: return String(localized: "Cell") + } + } +} + +struct HighlightRule: Identifiable, Equatable, Hashable, Codable, Sendable { + let id: UUID + var isEnabled: Bool + var columnName: String + var columnOccurrence: Int + var filterOperator: FilterOperator + var value: String + var secondValue: String? + var isCaseSensitive: Bool + var color: HighlightColor + var target: HighlightTarget + + init( + id: UUID = UUID(), + isEnabled: Bool = true, + columnName: String, + columnOccurrence: Int = 0, + filterOperator: FilterOperator = .equal, + value: String = "", + secondValue: String? = nil, + isCaseSensitive: Bool? = nil, + color: HighlightColor = .yellow, + target: HighlightTarget = .row + ) { + self.id = id + self.isEnabled = isEnabled + self.columnName = columnName + self.columnOccurrence = max(0, columnOccurrence) + self.filterOperator = filterOperator + self.value = value + self.secondValue = secondValue + self.isCaseSensitive = isCaseSensitive ?? filterOperator.defaultIsCaseSensitive + self.color = color + self.target = target + } + + private enum CodingKeys: String, CodingKey { + case id, isEnabled, columnName, columnOccurrence, filterOperator, value, secondValue + case isCaseSensitive, color, target + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedOperator = try container.decode(FilterOperator.self, forKey: .filterOperator) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + self.columnName = try container.decode(String.self, forKey: .columnName) + self.columnOccurrence = max(0, try container.decodeIfPresent(Int.self, forKey: .columnOccurrence) ?? 0) + self.filterOperator = decodedOperator + self.value = try container.decodeIfPresent(String.self, forKey: .value) ?? "" + self.secondValue = try container.decodeIfPresent(String.self, forKey: .secondValue) + self.isCaseSensitive = try container.decodeIfPresent(Bool.self, forKey: .isCaseSensitive) + ?? decodedOperator.defaultIsCaseSensitive + self.color = try container.decode(HighlightColor.self, forKey: .color) + self.target = try container.decodeIfPresent(HighlightTarget.self, forKey: .target) ?? .row + } + + var isValid: Bool { + guard !columnName.isEmpty else { return false } + guard filterOperator.requiresValue else { return true } + guard !value.isEmpty else { return false } + guard filterOperator.requiresSecondValue else { return true } + return !(secondValue?.isEmpty ?? true) + } + + func hasSameCondition(as other: HighlightRule) -> Bool { + columnName == other.columnName + && columnOccurrence == other.columnOccurrence + && filterOperator == other.filterOperator + && value == other.value + && (filterOperator.requiresSecondValue ? secondValue == other.secondValue : true) + && isCaseSensitive == other.isCaseSensitive + && target == other.target + } +} + +struct RowHighlight: Equatable, Sendable { + let rowRule: HighlightRule? + let cellRules: [Int: HighlightRule] + + static let none = RowHighlight(rowRule: nil, cellRules: [:]) + + var isEmpty: Bool { rowRule == nil && cellRules.isEmpty } + + var rowColor: HighlightColor? { rowRule?.color } + + func cellRule(forColumn column: Int) -> HighlightRule? { + cellRules[column] + } + + func describingRule(forColumn column: Int) -> HighlightRule? { + cellRules[column] ?? rowRule + } +} diff --git a/TablePro/Models/Highlight/HighlightRuleDescription.swift b/TablePro/Models/Highlight/HighlightRuleDescription.swift new file mode 100644 index 0000000000..18c1ab6a20 --- /dev/null +++ b/TablePro/Models/Highlight/HighlightRuleDescription.swift @@ -0,0 +1,67 @@ +// +// HighlightRuleDescription.swift +// TablePro +// + +import Foundation + +enum HighlightRuleDescription { + static let menuValueLimit = 32 + + static func condition(of rule: HighlightRule, valueLimit: Int? = nil) -> String { + condition( + columnName: rule.columnName, + filterOperator: rule.filterOperator, + value: rule.value, + secondValue: rule.secondValue, + valueLimit: valueLimit + ) + } + + static func condition( + columnName: String, + filterOperator: FilterOperator, + value: String, + secondValue: String?, + valueLimit: Int? = nil + ) -> String { + guard filterOperator.requiresValue else { + return String(format: String(localized: "%1$@ %2$@"), columnName, filterOperator.displayName) + } + + let first = truncated(value, to: valueLimit) + if filterOperator.requiresSecondValue { + return String( + format: String(localized: "%1$@ between “%2$@” and “%3$@”"), + columnName, + first, + truncated(secondValue ?? "", to: valueLimit) + ) + } + + return String( + format: String(localized: "%1$@ %2$@ “%3$@”"), + columnName, + operatorText(filterOperator), + first + ) + } + + static func truncated(_ value: String, to limit: Int?) -> String { + guard let limit, limit > 0 else { return value } + let source = value as NSString + guard source.length > limit else { return value } + let cut = source.rangeOfComposedCharacterSequence(at: limit).location + return source.substring(to: cut) + "\u{2026}" + } + + private static func operatorText(_ filterOperator: FilterOperator) -> String { + switch filterOperator { + case .equal, .notEqual, .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual: + return filterOperator.symbol + case .contains, .notContains, .startsWith, .endsWith, .isNull, .isNotNull, .isEmpty, + .isNotEmpty, .inList, .notInList, .between, .regex: + return filterOperator.displayName + } + } +} diff --git a/TablePro/Models/Query/DatabaseObjectRef.swift b/TablePro/Models/Query/DatabaseObjectRef.swift index fcbfb0fa7a..0fb52de0f9 100644 --- a/TablePro/Models/Query/DatabaseObjectRef.swift +++ b/TablePro/Models/Query/DatabaseObjectRef.swift @@ -2,7 +2,7 @@ // DatabaseObjectRef.swift // TablePro // -// Everything needed to find one routine, trigger or type again and read its source. +// Everything needed to find one routine, trigger, type or view again and read its source. // import Foundation @@ -12,13 +12,26 @@ enum DatabaseObjectKind: String, Codable, Sendable, Hashable { case function case trigger case userType + case view + case materializedView var sidebarObjectKind: SidebarObjectKind { switch self { - case .procedure: return .procedure - case .function: return .function - case .trigger: return .trigger - case .userType: return .type + case .procedure: return .procedure + case .function: return .function + case .trigger: return .trigger + case .userType: return .type + case .view: return .view + case .materializedView: return .materializedView + } + } + + /// The kind a table listing row opens as, or nil for a row whose source is its table DDL. + init?(tableType: TableInfo.TableType) { + switch tableType { + case .view: self = .view + case .materializedView: self = .materializedView + case .table, .foreignTable, .systemTable, .partitionedTable, .externalTable: return nil } } @@ -100,6 +113,12 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { ) } + /// A view or materialized view, whose source is read the way the Structure tab reads it. + init?(relation table: TableInfo, database: String, schema: String?) { + guard let kind = DatabaseObjectKind(tableType: table.type) else { return nil } + self.init(kind: kind, name: table.name, database: database, schema: schema) + } + init(userType: UserDefinedTypeInfo, database: String) { self.init( kind: .userType, @@ -122,7 +141,7 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { case .trigger: guard let table, !table.isEmpty else { return qualifiedName } return String(format: String(localized: "%1$@ on %2$@"), qualifiedName, table) - case .userType: + case .userType, .view, .materializedView: return qualifiedName } } @@ -171,7 +190,7 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { argumentSignature: argumentSignature, identity: identity ) - case .trigger, .userType: + case .trigger, .userType, .view, .materializedView: return nil } } diff --git a/TablePro/Models/Query/Delta.swift b/TablePro/Models/Query/Delta.swift index b17239aa27..18da0d0d89 100644 --- a/TablePro/Models/Query/Delta.swift +++ b/TablePro/Models/Query/Delta.swift @@ -14,4 +14,15 @@ enum Delta: Equatable { case fullReplace static let none = Delta.cellsChanged([]) + + var changesRowSet: Bool { + switch self { + case .rowsInserted(let indices), .rowsRemoved(let indices): + return !indices.isEmpty + case .fullReplace: + return true + case .cellChanged, .cellsChanged, .columnsReplaced: + return false + } + } } diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index d2735d792b..d4e76f1c31 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -37,6 +37,10 @@ internal struct EditorTabPayload: Codable, Hashable { internal let initialQuery: String? /// Whether this tab displays a database view (read-only) internal let isView: Bool + /// The object's own kind, which decides which structure edits the tab may offer. Carried beside + /// `isView` because that Bool answers a different question and cannot tell a materialized view + /// from a table. (#2726) + internal let objectType: TableInfo.TableType? /// Whether to show the structure view instead of data (for "Show Structure" context menu) internal let showStructure: Bool /// Whether to skip automatic query execution (used for restored tabs that should lazy-load) @@ -61,7 +65,7 @@ internal struct EditorTabPayload: Codable, Hashable { private enum CodingKeys: String, CodingKey { case id, connectionId, tabType, tableName, databaseName, schemaName - case initialQuery, isView, showStructure, skipAutoExecute, isPreview + case initialQuery, isView, objectType, showStructure, skipAutoExecute, isPreview case forcesNewTab case tabTitle case initialFilterState, sourceFileURL, erDiagramSchemaKey, objectRef, intent @@ -78,6 +82,7 @@ internal struct EditorTabPayload: Codable, Hashable { schemaName: String? = nil, initialQuery: String? = nil, isView: Bool = false, + objectType: TableInfo.TableType? = nil, showStructure: Bool = false, skipAutoExecute: Bool = false, isPreview: Bool = false, @@ -97,6 +102,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.schemaName = schemaName self.initialQuery = initialQuery self.isView = isView + self.objectType = objectType self.showStructure = showStructure self.skipAutoExecute = skipAutoExecute self.isPreview = isPreview @@ -119,6 +125,10 @@ internal struct EditorTabPayload: Codable, Hashable { schemaName = try container.decodeIfPresent(String.self, forKey: .schemaName) initialQuery = try container.decodeIfPresent(String.self, forKey: .initialQuery) isView = try container.decodeIfPresent(Bool.self, forKey: .isView) ?? false + /// A raw String, so a spelling a newer build invents decodes to nil rather than throwing and + /// losing the whole payload. + objectType = try container.decodeIfPresent(String.self, forKey: .objectType) + .flatMap(TableInfo.TableType.init(rawValue:)) showStructure = try container.decodeIfPresent(Bool.self, forKey: .showStructure) ?? false skipAutoExecute = try container.decodeIfPresent(Bool.self, forKey: .skipAutoExecute) ?? false isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false @@ -146,6 +156,7 @@ internal struct EditorTabPayload: Codable, Hashable { try container.encodeIfPresent(schemaName, forKey: .schemaName) try container.encodeIfPresent(initialQuery, forKey: .initialQuery) try container.encode(isView, forKey: .isView) + try container.encodeIfPresent(objectType?.rawValue, forKey: .objectType) try container.encode(showStructure, forKey: .showStructure) try container.encode(skipAutoExecute, forKey: .skipAutoExecute) try container.encode(isPreview, forKey: .isPreview) @@ -168,6 +179,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.schemaName = tab.tableContext.schemaName self.initialQuery = tab.content.query self.isView = tab.tableContext.isView + self.objectType = tab.tableContext.objectType self.showStructure = tab.display.resultsViewMode == .structure self.skipAutoExecute = skipAutoExecute self.isPreview = false diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index a75b0316da..7e933dd217 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -120,7 +120,7 @@ struct TableInfo: Identifiable, Hashable, Sendable { let schema: String? let comment: String? - enum TableType: String, Sendable { + enum TableType: String, Sendable, CaseIterable { case table = "TABLE" case view = "VIEW" case materializedView = "MATERIALIZED VIEW" diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index ece3ac2985..0ea6b0c79a 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -74,6 +74,7 @@ struct QueryTab: Identifiable, Equatable { /// run with no `DataGridView` in the view tree. Living on the grid's SwiftUI coordinator meant /// switching result mode did not hide the order, it deleted it. (#2251) var valueFilter: GridValueFilterState + var sessionHighlightRules: [HighlightRule] = [] var pagination: PaginationState var chartConfiguration: ResultChartConfiguration var hasUserInteraction: Bool @@ -202,7 +203,8 @@ struct QueryTab: Identifiable, Equatable { databaseName: persisted.databaseName, schemaName: persisted.schemaName, isEditable: persisted.tabType == .table && !persisted.isView, - isView: persisted.isView + isView: persisted.isView, + objectType: persisted.objectTypeRawValue.flatMap(TableInfo.TableType.init(rawValue:)) ) self.display = TabDisplayState( erDiagramSchemaKey: persisted.erDiagramSchemaKey, @@ -376,6 +378,7 @@ struct QueryTab: Identifiable, Equatable { tabType: tabType, tableName: tableContext.tableName, isView: tableContext.isView, + objectTypeRawValue: tableContext.objectType?.rawValue, databaseName: tableContext.databaseName, schemaName: tableContext.schemaName, sourceFileURL: content.sourceFileURL, @@ -407,10 +410,12 @@ struct QueryTab: Identifiable, Equatable { && lhs.pagination == rhs.pagination && lhs.sortState == rhs.sortState && lhs.valueFilter == rhs.valueFilter + && lhs.sessionHighlightRules == rhs.sessionHighlightRules && lhs.chartConfiguration == rhs.chartConfiguration && lhs.display == rhs.display && lhs.tableContext.isEditable == rhs.tableContext.isEditable && lhs.tableContext.isView == rhs.tableContext.isView + && lhs.tableContext.objectType == rhs.tableContext.objectType && lhs.tabType == rhs.tabType && lhs.isPreview == rhs.isPreview && lhs.hasUserInteraction == rhs.hasUserInteraction diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index eab0a2c32c..7131737823 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -299,6 +299,7 @@ final class QueryTabManager { databaseName: String = "", schemaName: String? = nil, isView: Bool = false, + objectType: TableInfo.TableType? = nil, isPreview: Bool = false, allowsDuplicate: Bool = false, quoteIdentifier: ((String) -> String)? = nil @@ -330,6 +331,7 @@ final class QueryTabManager { ) newTab.pagination = PaginationState(pageSize: pageSize) newTab.tableContext.databaseName = databaseName + newTab.tableContext.objectType = objectType newTab.tableContext.schemaName = schemaName newTab.isPreview = isPreview tabs.append(newTab) @@ -420,6 +422,8 @@ final class QueryTabManager { case .function: format = String(localized: "Function: %@") case .trigger: format = String(localized: "Trigger: %@") case .userType: format = String(localized: "Type: %@") + case .view: format = String(localized: "View: %@") + case .materializedView: format = String(localized: "Materialized View: %@") } return String(format: format, objectRef.displayIdentity) } @@ -443,7 +447,8 @@ final class QueryTabManager { @discardableResult func replaceTabContent( tableName: String, databaseType: DatabaseType = .mysql, - isView: Bool = false, databaseName: String = "", + isView: Bool = false, objectType: TableInfo.TableType? = nil, + databaseName: String = "", schemaName: String? = nil, isPreview: Bool = false, quoteIdentifier: ((String) -> String)? = nil ) throws -> Bool { @@ -484,6 +489,7 @@ final class QueryTabManager { tab.pendingChanges = TabChangeSnapshot() tab.hasUserInteraction = false tab.tableContext.isView = isView + tab.tableContext.objectType = objectType tab.tableContext.isEditable = !isView tab.filterState = TabFilterState() tab.columnLayout = ColumnLayoutState() diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index e4480deabf..7a0cef3658 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -31,6 +31,10 @@ struct PersistedTab: Codable { let tabType: TabType let tableName: String? var isView: Bool = false + /// The object's own kind, as its `TableInfo.TableType` raw value. Optional and a raw String so a + /// file written before this existed decodes to nil, and a spelling a newer build invents decodes + /// to nil too rather than throwing and taking the whole tab aggregate with it. + var objectTypeRawValue: String? var databaseName: String = "" var schemaName: String? var sourceFileURL: URL? @@ -61,6 +65,7 @@ struct PersistedTab: Codable { tabType: TabType, tableName: String?, isView: Bool = false, + objectTypeRawValue: String? = nil, databaseName: String = "", schemaName: String? = nil, sourceFileURL: URL? = nil, @@ -84,6 +89,7 @@ struct PersistedTab: Codable { self.tabType = tabType self.tableName = tableName self.isView = isView + self.objectTypeRawValue = objectTypeRawValue self.databaseName = databaseName self.schemaName = schemaName self.sourceFileURL = sourceFileURL @@ -103,7 +109,7 @@ struct PersistedTab: Codable { } private enum CodingKeys: String, CodingKey { - case id, title, query, tabType, tableName, isView, databaseName, schemaName + case id, title, query, tabType, tableName, isView, objectTypeRawValue, databaseName, schemaName case sourceFileURL, erDiagramSchemaKey, objectRef, queryParameters case sortColumns, sortSource, restoredPage, restoredPageSize, cursorOffset, cursorLength case collapsedFoldRanges @@ -119,6 +125,7 @@ struct PersistedTab: Codable { tabType = try container.decode(TabType.self, forKey: .tabType) tableName = try container.decodeIfPresent(String.self, forKey: .tableName) isView = try container.decodeIfPresent(Bool.self, forKey: .isView) ?? false + objectTypeRawValue = try container.decodeIfPresent(String.self, forKey: .objectTypeRawValue) databaseName = try container.decodeIfPresent(String.self, forKey: .databaseName) ?? "" schemaName = try container.decodeIfPresent(String.self, forKey: .schemaName) sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) @@ -141,17 +148,17 @@ struct PersistedTab: Codable { struct TabChangeSnapshot: Equatable { var changes: [RowChange] - var deletedRowIndices: Set - var insertedRowIndices: Set - var modifiedCells: [Int: Set] - var insertedRowData: [Int: [PluginCellValue]] + var deletedRowIDs: Set + var insertedRowIDs: Set + var modifiedCells: [RowID: Set] + var insertedRowData: [RowID: [PluginCellValue]] var primaryKeyColumns: [String] var columns: [String] init() { self.changes = [] - self.deletedRowIndices = [] - self.insertedRowIndices = [] + self.deletedRowIDs = [] + self.insertedRowIDs = [] self.modifiedCells = [:] self.insertedRowData = [:] self.primaryKeyColumns = [] @@ -159,7 +166,7 @@ struct TabChangeSnapshot: Equatable { } var hasChanges: Bool { - !changes.isEmpty || !insertedRowIndices.isEmpty || !deletedRowIndices.isEmpty + !changes.isEmpty || !insertedRowIDs.isEmpty || !deletedRowIDs.isEmpty } } @@ -564,6 +571,22 @@ struct TabTableContext: Equatable { var isEditable: Bool = false var isView: Bool = false + /// The object's own kind, carried beside `isView` rather than replacing it. + /// + /// The two answer different questions. `isView` decides whether the *rows* may be written, which + /// a dozen Bool-only carriers already speak (deeplinks, the URL parser, scripting, recents), and + /// it comes from `allowsRowEditing`, which is deliberately true for a materialized view because + /// a matview does hold rows. This says which of seven kinds the object is, which is the only + /// thing that can say which *structure* edits it accepts. Conflating them is the defect. (#2726) + /// + /// Nil on a tab restored from a file written before this existed, and on any path that never + /// learned the kind; `resolvedObjectKind()` falls back to what `isView` can still tell us. + var objectType: TableInfo.TableType? + + func resolvedObjectKind() -> TableInfo.TableType { + objectType ?? (isView ? .view : .table) + } + var primaryKeyColumn: String? { primaryKeyColumns.first } /// A tab opened without an explicit database carries an empty name and follows the window's @@ -657,6 +680,7 @@ struct TabDisplayState: Equatable { var isResultsCollapsed: Bool = false var resultSets: [ResultSet] = [] var activeResultSetId: UUID? + var highlightRulesPresentationRequest: Int = 0 var activeResultSet: ResultSet? { guard let id = activeResultSetId else { return resultSets.last } @@ -698,5 +722,6 @@ struct TabDisplayState: Equatable { && lhs.isResultsCollapsed == rhs.isResultsCollapsed && lhs.resultSets.map(\.id) == rhs.resultSets.map(\.id) && lhs.activeResultSetId == rhs.activeResultSetId + && lhs.highlightRulesPresentationRequest == rhs.highlightRulesPresentationRequest } } diff --git a/TablePro/Models/Query/ResultOrigin.swift b/TablePro/Models/Query/ResultOrigin.swift index e1208ffeb1..cdf986a821 100644 --- a/TablePro/Models/Query/ResultOrigin.swift +++ b/TablePro/Models/Query/ResultOrigin.swift @@ -19,6 +19,9 @@ struct ResultOrigin: Equatable { var primaryKeyColumns: [String] var isEditable: Bool var isView: Bool + /// The kind of object these rows came from, so a result switched back to restores the tab's + /// structure gate rather than the table `isView` alone would imply. (#2726) + var objectType: TableInfo.TableType? /// Whether anything has actually looked up this table's key columns. An empty /// `primaryKeyColumns` means two different things: a table that genuinely has no key, which @@ -33,6 +36,7 @@ struct ResultOrigin: Equatable { primaryKeyColumns: [String] = [], isEditable: Bool = false, isView: Bool = false, + objectType: TableInfo.TableType? = nil, keysResolved: Bool = false ) { self.tableName = tableName @@ -41,6 +45,7 @@ struct ResultOrigin: Equatable { self.primaryKeyColumns = primaryKeyColumns self.isEditable = isEditable self.isView = isView + self.objectType = objectType self.keysResolved = keysResolved } } diff --git a/TablePro/Models/Query/ResultStatusModel.swift b/TablePro/Models/Query/ResultStatusModel.swift index f58e2c1ad3..ccb0032825 100644 --- a/TablePro/Models/Query/ResultStatusModel.swift +++ b/TablePro/Models/Query/ResultStatusModel.swift @@ -41,6 +41,7 @@ struct ResultStatusControls: Equatable { var showsCountInProgress = false var showsFetchAll = false var showsColumns = false + var showsHighlightRules = false var showsFilters = false var showsPagination = false /// First, Previous, Next, Last and the page number, which an engine that cannot skip rows has @@ -115,6 +116,7 @@ struct ResultStatusModel: Equatable { && !pagination.isLoadingMore controls.showsColumns = viewMode.showsColumnControls && describesAResult + controls.showsHighlightRules = viewMode == .data && describesAResult controls.showsFilters = viewMode.showsRowFilters && isTable && snapshot.hasTableName controls.showsPagination = viewMode.showsResultScope && isTable && snapshot.hasTableName controls.showsPageNavigation = controls.showsPagination && snapshot.paginationCapability.allowsSeeking diff --git a/TablePro/Models/Query/TabNavigationHistory.swift b/TablePro/Models/Query/TabNavigationHistory.swift index a87c3aa485..3d4f3c9b3d 100644 --- a/TablePro/Models/Query/TabNavigationHistory.swift +++ b/TablePro/Models/Query/TabNavigationHistory.swift @@ -24,6 +24,9 @@ struct TabNavigationEntry: Equatable { var databaseName: String var schemaName: String? var isView: Bool + /// The object's own kind, so Back and Forward put the tab on a materialized view rather than + /// falling back to the table the `isView` Bool cannot distinguish it from. (#2726) + var objectType: TableInfo.TableType? var resultsViewMode: ResultsViewMode var filterState: TabFilterState var sortColumns: [PersistedSortColumn] diff --git a/TablePro/Models/Query/TabSession.swift b/TablePro/Models/Query/TabSession.swift index 0ce770f54b..dfb70d3aa4 100644 --- a/TablePro/Models/Query/TabSession.swift +++ b/TablePro/Models/Query/TabSession.swift @@ -38,11 +38,19 @@ final class TabSession: Identifiable { /// be dropped exactly then and kept across an edit. var bufferEpoch: Int + /// Bumped when rows arrive, leave or are replaced, and never for a cell edit, which is the one + /// distinction the other two counters cannot draw: `dataRevision` moves for an edit and + /// `bufferEpoch` sits still for an insert or a delete. Anything that answers "which row sits at + /// this display position" has to hold still across an edit and move with the row set, because an + /// edit leaves the rows where they are and the grid goes on showing them there. + var rowSetRevision: Int + init(id: UUID = UUID()) { self.id = id self.tableRows = TableRows() self.isEvicted = false self.dataRevision = 0 self.bufferEpoch = 0 + self.rowSetRevision = 0 } } diff --git a/TablePro/Models/Query/TabSessionRegistry.swift b/TablePro/Models/Query/TabSessionRegistry.swift index 809e0c8c84..c73ccac591 100644 --- a/TablePro/Models/Query/TabSessionRegistry.swift +++ b/TablePro/Models/Query/TabSessionRegistry.swift @@ -43,6 +43,7 @@ final class TabSessionRegistry { session.isEvicted = false session.dataRevision &+= 1 session.bufferEpoch &+= 1 + session.rowSetRevision &+= 1 } /// A mutation of what the tab already holds, so it cannot resurrect a tab that holds nothing. @@ -53,15 +54,20 @@ final class TabSessionRegistry { /// mutation that leaves the buffer empty leaves a tab with no rows that `canAutoLoadTableTab` /// reads as already loaded, and eviction cannot re-mark it because it has nothing left to lose: /// the grid stays empty until an explicit refresh. - func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Void) { + @discardableResult + func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Delta) -> Delta { let session = ensureSession(for: tabId) var rows = session.tableRows - mutate(&rows) + let delta = mutate(&rows) session.tableRows = rows if !rows.rows.isEmpty { session.isEvicted = false } session.dataRevision &+= 1 + if delta.changesRowSet { + session.rowSetRevision &+= 1 + } + return delta } func removeTableRows(for tabId: UUID) { @@ -70,6 +76,7 @@ final class TabSessionRegistry { session.isEvicted = false session.dataRevision &+= 1 session.bufferEpoch &+= 1 + session.rowSetRevision &+= 1 } func isEvicted(_ tabId: UUID) -> Bool { @@ -88,6 +95,7 @@ final class TabSessionRegistry { session.isEvicted = true session.dataRevision &+= 1 session.bufferEpoch &+= 1 + session.rowSetRevision &+= 1 } private func ensureSession(for tabId: UUID) -> TabSession { diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index e91db7569a..c6c45c23f3 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -119,9 +119,10 @@ struct TableRows: Sendable { } @discardableResult - mutating func appendInsertedRow(values: [PluginCellValue]) -> Delta { + mutating func appendInsertedRow(id: RowID = .inserted(UUID()), values: [PluginCellValue]) -> Delta { + guard indexByID[id] == nil else { return .none } let normalized = Self.normalize(values: values, toCount: columns.count) - let row = Row(id: .inserted(UUID()), values: normalized) + let row = Row(id: id, values: normalized) let newIndex = rows.count rows.append(row) indexByID[row.id] = newIndex @@ -129,10 +130,14 @@ struct TableRows: Sendable { } @discardableResult - mutating func insertInsertedRow(at index: Int, values: [PluginCellValue]) -> Delta { - guard index >= 0, index <= rows.count else { return .none } + mutating func insertInsertedRow( + at index: Int, + id: RowID = .inserted(UUID()), + values: [PluginCellValue] + ) -> Delta { + guard index >= 0, index <= rows.count, indexByID[id] == nil else { return .none } let normalized = Self.normalize(values: values, toCount: columns.count) - let row = Row(id: .inserted(UUID()), values: normalized) + let row = Row(id: id, values: normalized) rows.insert(row, at: index) for offset in index.. [String] { switch databaseType { - case .mysql, .tidb: + case .mysql, .tidb, .oceanbase: return ["CURRENT_TIMESTAMP", "(UUID())", "(CURRENT_DATE)"] case .mariadb: return ["CURRENT_TIMESTAMP", "uuid()", "curdate()"] diff --git a/TablePro/Models/Schema/ColumnReorderSupport.swift b/TablePro/Models/Schema/ColumnReorderSupport.swift index 7c665981ff..f74ae43d36 100644 --- a/TablePro/Models/Schema/ColumnReorderSupport.swift +++ b/TablePro/Models/Schema/ColumnReorderSupport.swift @@ -82,23 +82,24 @@ enum ColumnMove { /// the drop, while the drag itself was offered unconditionally, so 30 engines lifted the row, /// opened the insertion gap, took the drop and did nothing. enum ColumnReorderPolicy { + /// - Parameter kindRefusal: Why the object's own kind refuses a reorder, nil when it accepts one. + /// Supplied by `StructureEditEligibility`, because only the per-kind matrix knows which of + /// seven object kinds is in front of the user and can therefore word the refusal. (#2726) static func resolve( support: ColumnReorderSupport, engineName: String, isColumnsTab: Bool, - isTable: Bool, + kindRefusal: String?, canEditSchema: Bool, hasStagedChanges: Bool, isRearranged: Bool ) -> ColumnReorderAvailability { guard isColumnsTab else { return .notApplicable } /// Every mechanism emits table DDL, and the SQLite one looks the table up by - /// `sqlite_master.type = 'table'`, so a view drag would end in an error rather than an - /// explanation. A view's column order comes from its own `SELECT`. - guard isTable else { - return .unavailable( - reason: String(localized: "A view's column order comes from its query. Edit the view to change it.") - ) + /// `sqlite_master.type = 'table'`, so a drag on anything else would end in an error rather + /// than an explanation. + if let kindRefusal { + return .unavailable(reason: kindRefusal) } guard canEditSchema else { return .unavailable( diff --git a/TablePro/Models/Schema/ForeignKeyDialect.swift b/TablePro/Models/Schema/ForeignKeyDialect.swift index 7253c6a0df..fe1f2d3fce 100644 --- a/TablePro/Models/Schema/ForeignKeyDialect.swift +++ b/TablePro/Models/Schema/ForeignKeyDialect.swift @@ -44,7 +44,7 @@ struct ForeignKeyDialect: Equatable, Sendable { allowsQualifiedReferencedTable: false, allowsOmittedReferencedColumns: true ) - case .mysql, .mariadb, .tidb: + case .mysql, .mariadb, .tidb, .oceanbase: return ForeignKeyDialect( deleteActions: [.noAction, .restrict, .cascade, .setNull], updateActions: [.noAction, .restrict, .cascade, .setNull], diff --git a/TablePro/Models/Schema/ForeignKeyEditSupport.swift b/TablePro/Models/Schema/ForeignKeyEditSupport.swift index f2dc67a02f..fc1afab234 100644 --- a/TablePro/Models/Schema/ForeignKeyEditSupport.swift +++ b/TablePro/Models/Schema/ForeignKeyEditSupport.swift @@ -51,13 +51,29 @@ enum ForeignKeyEditAvailability: Sendable, Equatable { if case .unavailable(let reason) = self { return reason } return nil } + + /// The same answer in the vocabulary the rest of the Structure tab speaks, so one gate can hand + /// every call site a single type while this policy stays the owner of the foreign key wording. + var structureEditAvailability: StructureEditAvailability { + switch self { + case .available: + return .available + case .unavailable(let reason): + return .unavailable(reason: reason) + } + } } enum ForeignKeyEditPolicy { + /// - Parameter kindRefusal: Why the object's own kind refuses a foreign key edit, nil when it + /// accepts one. Supplied by `StructureEditEligibility`, because only the per-kind matrix knows + /// which of seven object kinds is in front of the user. This used to be an `isTable` Bool + /// derived from `allowsRowEditing`, which is true for a materialized view, so the "+" was + /// offered over an `ADD CONSTRAINT` PostgreSQL always refuses. (#2726) static func resolve( support: ForeignKeyEditSupport, engineName: String, - isTable: Bool, + kindRefusal: String?, canEditSchema: Bool ) -> ForeignKeyEditAvailability { guard canEditSchema else { @@ -65,13 +81,11 @@ enum ForeignKeyEditPolicy { reason: String(format: String(localized: "%@ cannot edit a table's structure."), engineName) ) } - /// A view has no constraints of its own, and both mechanisms emit table DDL. The rebuild one - /// looks the table up by `sqlite_master.type = 'table'`, so a view would end in an error - /// rather than an explanation. - guard isTable else { - return .unavailable( - reason: String(localized: "A view has no foreign keys. Edit the tables its query reads.") - ) + /// Both mechanisms emit table DDL. The rebuild one looks the table up by + /// `sqlite_master.type = 'table'`, so anything else would end in an error rather than an + /// explanation. + if let kindRefusal { + return .unavailable(reason: kindRefusal) } switch support { case .unsupported: @@ -97,4 +111,9 @@ enum ForeignKeyEditPolicy { struct SchemaEditingSupport: Sendable, Equatable { var columnReorder: ColumnReorderSupport = .unsupported var foreignKeyEdit: ForeignKeyEditSupport = .unsupported + + /// Which structure edits each kind of object accepts. Defaults to tables only, so an engine + /// nobody has curated never offers a view, materialized view or foreign table an edit its server + /// would refuse. + var structureEdits: StructureObjectEditMatrix = .tablesOnly } diff --git a/TablePro/Models/Settings/LegacyThemeFonts.swift b/TablePro/Models/Settings/LegacyThemeFonts.swift new file mode 100644 index 0000000000..4a767deb2f --- /dev/null +++ b/TablePro/Models/Settings/LegacyThemeFonts.swift @@ -0,0 +1,67 @@ +import Foundation +import os + +/// Reads the `fonts` object out of a theme file written before fonts moved into settings. It is +/// the only code that still opens the old format, it runs once, and it writes nothing back: the +/// theme file keeps its own shape and is rejected by the current loader like any other old file. +internal enum LegacyThemeFonts { + private static let logger = Logger(subsystem: "com.TablePro", category: "LegacyThemeFonts") + + private struct Document: Decodable { + struct Fonts: Decodable { + var editorFontFamily: String? + var editorFontSize: Int? + var dataGridFontFamily: String? + var dataGridFontSize: Int? + } + + var fonts: Fonts? + } + + internal static func read(preferring appearance: AppearanceSettings) -> TypographySettings { + let candidates = [appearance.preferredLightThemeId, appearance.preferredDarkThemeId] + + for id in candidates { + guard let fonts = fonts(forThemeId: id) else { continue } + return settings(from: fonts) + } + + return .default + } + + private static func fonts(forThemeId id: String) -> Document.Fonts? { + let directories = [userThemesDirectory, userThemesDirectory.appendingPathComponent("Registry")] + + (Bundle.main.resourceURL.map { [$0] } ?? []) + + for directory in directories { + let url = directory.appendingPathComponent("\(id).json", isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { continue } + + do { + let document = try JSONDecoder().decode(Document.self, from: try Data(contentsOf: url)) + guard let fonts = document.fonts else { continue } + return fonts + } catch { + logger.error("Could not read legacy fonts from \(url.lastPathComponent): \(error)") + } + } + + return nil + } + + private static func settings(from fonts: Document.Fonts) -> TypographySettings { + let fallback = TypographySettings.default + + return TypographySettings( + editorFontFamily: fonts.editorFontFamily ?? fallback.editorFontFamily, + editorFontSize: TypographySettings.clamp(fonts.editorFontSize ?? fallback.editorFontSize), + dataGridFontFamily: fonts.dataGridFontFamily ?? fallback.dataGridFontFamily, + dataGridFontSize: TypographySettings.clamp(fonts.dataGridFontSize ?? fallback.dataGridFontSize) + ) + } + + private static var userThemesDirectory: URL { + AppStorageEnvironment.shared.applicationSupportRoot + .appendingPathComponent("TablePro/Themes", isDirectory: true) + } +} diff --git a/TablePro/Models/Settings/TypographySettings.swift b/TablePro/Models/Settings/TypographySettings.swift new file mode 100644 index 0000000000..dfac2c91ee --- /dev/null +++ b/TablePro/Models/Settings/TypographySettings.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Device-local on purpose. Fonts used to live inside the theme file, so a zoom shortcut rewrote +/// and saved a whole theme. Moving them into a synced settings category would be worse: sync +/// replaces the whole struct on apply, so a Mac still on an older build would push a payload +/// without these keys and reset the fonts on every other Mac, and each zoom press would push a +/// record. The one range here is shared by the pickers, the zoom commands and the font caches; +/// three disagreeing ranges are what let zoom store a size the renderer clamped away. +internal struct TypographySettings: Codable, Equatable, Sendable { + internal var editorFontFamily: String + internal var editorFontSize: Int + internal var dataGridFontFamily: String + internal var dataGridFontSize: Int + + internal static let sizeRange = 10...24 + internal static let systemMonoFamily = "System Mono" + + internal static let `default` = TypographySettings( + editorFontFamily: systemMonoFamily, + editorFontSize: 13, + dataGridFontFamily: systemMonoFamily, + dataGridFontSize: 13 + ) + + internal init( + editorFontFamily: String = systemMonoFamily, + editorFontSize: Int = 13, + dataGridFontFamily: String = systemMonoFamily, + dataGridFontSize: Int = 13 + ) { + self.editorFontFamily = editorFontFamily + self.editorFontSize = editorFontSize + self.dataGridFontFamily = dataGridFontFamily + self.dataGridFontSize = dataGridFontSize + } + + internal init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = TypographySettings.default + + editorFontFamily = try container.decodeIfPresent(String.self, forKey: .editorFontFamily) + ?? fallback.editorFontFamily + editorFontSize = try container.decodeIfPresent(Int.self, forKey: .editorFontSize) + ?? fallback.editorFontSize + dataGridFontFamily = try container.decodeIfPresent(String.self, forKey: .dataGridFontFamily) + ?? fallback.dataGridFontFamily + dataGridFontSize = try container.decodeIfPresent(Int.self, forKey: .dataGridFontSize) + ?? fallback.dataGridFontSize + } + + internal var clampedEditorFontSize: Int { + Self.clamp(editorFontSize) + } + + internal var clampedDataGridFontSize: Int { + Self.clamp(dataGridFontSize) + } + + internal static func clamp(_ size: Int) -> Int { + min(max(size, sizeRange.lowerBound), sizeRange.upperBound) + } +} diff --git a/TablePro/Models/UI/DataGridConfiguration.swift b/TablePro/Models/UI/DataGridConfiguration.swift index d11ed5b666..965a571247 100644 --- a/TablePro/Models/UI/DataGridConfiguration.swift +++ b/TablePro/Models/UI/DataGridConfiguration.swift @@ -21,6 +21,13 @@ struct DataGridConfiguration: Equatable { var showRowNumbers: Bool = true var hiddenColumns: Set = [] + /// Headings whose cells this grid must not let the user change, even though the rest of the grid + /// is editable. The Structure tab sets it per object kind: PostgreSQL takes a view's + /// `RENAME COLUMN` and `SET DEFAULT` and refuses its `SET NOT NULL` and `SET DATA TYPE`, so Name + /// and Default stay open while Nullable and Type lock. Name-keyed because that is the handle + /// `isColumnWritable` already works in. (#2726) + var lockedColumns: Set = [] + /// This grid shows rows of a result, so the Data Grid sort preferences apply to it. /// /// `tabType` cannot answer this: the Structure grid runs inside a `.table` tab and lists columns, diff --git a/TablePro/Models/UI/JSONTreeNode.swift b/TablePro/Models/UI/JSONTreeNode.swift index 8ed335fdc7..1b6ccd3b6f 100644 --- a/TablePro/Models/UI/JSONTreeNode.swift +++ b/TablePro/Models/UI/JSONTreeNode.swift @@ -27,13 +27,14 @@ internal enum JSONValueType { } } + @MainActor var color: NSColor { switch self { - case .object, .array: return .systemBlue - case .string: return .systemRed - case .number: return .systemPurple - case .boolean, .null: return .systemOrange - case .truncated: return .secondaryLabelColor + case .object, .array: return ThemeEngine.shared.palette[.syntaxKeyword] + case .string: return ThemeEngine.shared.palette[.syntaxString] + case .number: return ThemeEngine.shared.palette[.syntaxNumber] + case .boolean, .null: return ThemeEngine.shared.palette[.syntaxNull] + case .truncated: return ThemeEngine.shared.palette[.panelSecondaryText] } } } diff --git a/TablePro/Models/UI/PhpTreeNode.swift b/TablePro/Models/UI/PhpTreeNode.swift index 3683a5e417..11eec35a0e 100644 --- a/TablePro/Models/UI/PhpTreeNode.swift +++ b/TablePro/Models/UI/PhpTreeNode.swift @@ -35,13 +35,14 @@ internal enum PhpNodeType { } } + @MainActor var color: NSColor { switch self { - case .array, .object: return .systemBlue - case .string: return .systemRed - case .int, .float: return .systemPurple - case .bool, .null: return .systemOrange - case .serializable: return .systemTeal + case .array, .object: return ThemeEngine.shared.palette[.syntaxKeyword] + case .string: return ThemeEngine.shared.palette[.syntaxString] + case .int, .float: return ThemeEngine.shared.palette[.syntaxNumber] + case .bool, .null: return ThemeEngine.shared.palette[.syntaxNull] + case .serializable: return ThemeEngine.shared.palette[.syntaxType] case .reference: return .systemGray case .unsupported, .truncated: return .secondaryLabelColor } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 5d33321d19..9af84bbc1f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -142543,6 +142543,74 @@ } } }, + "Skipped settings this server does not recognize: %@." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 서버가 인식하지 못하는 설정을 건너뛰었습니다: %@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu sunucunun tanımadığı ayarlar atlandı: %@." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bỏ qua các thiết lập mà máy chủ này không nhận ra: %@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已跳过此服务器无法识别的设置:%@。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已略過此伺服器無法辨識的設定:%@。" + } + } + } + }, + "Skipped the %@ setting, which this server does not recognize." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 서버가 인식하지 못하는 %@ 설정을 건너뛰었습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu sunucunun tanımadığı %@ ayarı atlandı." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bỏ qua thiết lập %@ mà máy chủ này không nhận ra." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已跳过此服务器无法识别的 %@ 设置。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已略過此伺服器無法辨識的 %@ 設定。" + } + } + } + }, "Skips foreign key constraint checks for this operation" : { "extractionState" : "stale", "localizations" : { diff --git a/TablePro/Resources/Themes/tablepro.default-dark.json b/TablePro/Resources/Themes/tablepro.default-dark.json deleted file mode 100644 index 48e2450078..0000000000 --- a/TablePro/Resources/Themes/tablepro.default-dark.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "id": "tablepro.default-dark", - "name": "Default Dark", - "version": 1, - "appearance": "dark", - "author": "TablePro", - "editor": { - "background": "#1E1E1E", - "text": "#D4D4D4", - "cursor": "#007AFF", - "currentLineHighlight": "#007AFF14", - "currentStatementHighlight": "#FFFFFF0F", - "selection": "#264F78", - "lineNumber": "#858585", - "invisibles": "#4D4D4D", - "syntax": { - "keyword": "#569CD6", - "string": "#CE9178", - "number": "#B5CEA8", - "comment": "#6A9955", - "null": "#FF8C00", - "operator": "#D4D4D4", - "function": "#DCDCAA", - "type": "#4EC9B0" - } - }, - "dataGrid": { - "background": "#1E1E1E", - "text": "#D4D4D4", - "alternateRow": "#FFFFFF08", - "nullValue": "#858585", - "boolTrue": "#32D74B", - "boolFalse": "#FF453A", - "rowNumber": "#858585", - "modified": "#FFD60A4D", - "inserted": "#32D74B26", - "deleted": "#FF453A26", - "deletedText": "#FF453A80", - "focusBorder": "#007AFF" - }, - "ui": { - "accentColor": null, - "status": { - "success": "#32D74B", - "warning": "#FF9F0A", - "error": "#FF453A", - "info": "#0A84FF" - }, - "badges": { - "background": "#3C3C3C", - "primaryKey": "#0A84FF26", - "autoIncrement": "#BF5AF226" - } - }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 - } -} diff --git a/TablePro/Resources/Themes/tablepro.default-light.json b/TablePro/Resources/Themes/tablepro.default-light.json deleted file mode 100644 index 8d4b7b0115..0000000000 --- a/TablePro/Resources/Themes/tablepro.default-light.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "id": "tablepro.default-light", - "name": "Default Light", - "version": 1, - "appearance": "light", - "author": "TablePro", - "editor": { - "background": "#FFFFFF", - "text": "#000000", - "cursor": "#007AFF", - "currentLineHighlight": "#007AFF14", - "currentStatementHighlight": "#0A0A0A0F", - "selection": "#B4D8FD", - "lineNumber": "#8E8E93", - "invisibles": "#C7C7CC", - "syntax": { - "keyword": "#0A49A5", - "string": "#C41A16", - "number": "#6C36A9", - "comment": "#007400", - "null": "#C55B00", - "operator": "#000000", - "function": "#326D74", - "type": "#3F6E74" - } - }, - "dataGrid": { - "background": "#FFFFFF", - "text": "#000000", - "alternateRow": "#F5F5F5", - "nullValue": "#8E8E93", - "boolTrue": "#248A3D", - "boolFalse": "#D70015", - "rowNumber": "#8E8E93", - "modified": "#FFD60A4D", - "inserted": "#34C7594D", - "deleted": "#FF3B304D", - "deletedText": "#FF3B3080", - "focusBorder": "#007AFF" - }, - "ui": { - "accentColor": null, - "status": { - "success": "#248A3D", - "warning": "#C55B00", - "error": "#D70015", - "info": "#007AFF" - }, - "badges": { - "background": "#E5E5EA", - "primaryKey": "#007AFF26", - "autoIncrement": "#AF52DE26" - } - }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 - } -} diff --git a/TablePro/Resources/Themes/tablepro.dracula.json b/TablePro/Resources/Themes/tablepro.dracula.json index 77a1459646..1148a8f2f9 100644 --- a/TablePro/Resources/Themes/tablepro.dracula.json +++ b/TablePro/Resources/Themes/tablepro.dracula.json @@ -1,81 +1,62 @@ { - "id": "tablepro.dracula", - "name": "Dracula", - "version": 1, - "appearance": "dark", - "author": "TablePro", - "editor": { - "background": "#282A36", - "text": "#F8F8F2", - "cursor": "#F8F8F2", - "currentLineHighlight": "#44475A", - "currentStatementHighlight": "#FFFFFF0F", - "selection": "#44475A", - "lineNumber": "#6272A4", - "invisibles": "#424450", - "syntax": { - "keyword": "#FF79C6", - "string": "#F1FA8C", - "number": "#BD93F9", - "comment": "#6272A4", - "null": "#FFB86C", - "operator": "#FF79C6", - "function": "#50FA7B", - "type": "#8BE9FD" - } - }, + "appearance": "dark", + "author": "TablePro", + "content": { "dataGrid": { - "background": "#282A36", - "text": "#F8F8F2", - "alternateRow": "#21222C", - "nullValue": "#6272A4", - "boolTrue": "#50FA7B", - "boolFalse": "#FF5555", - "rowNumber": "#6272A4", - "modified": "#FFB86C4D", - "inserted": "#50FA7B26", - "deleted": "#FF555526", - "deletedText": "#FF555580", - "focusBorder": "#BD93F9" + "alternateRow": "#21222C", + "background": "#282A36", + "boolFalse": "#FF5555", + "boolTrue": "#50FA7B", + "deleted": "#FF555526", + "deletedText": "#FF555580", + "focusBorder": "#BD93F9", + "gridLine": "#44475A", + "headerBackground": "#21222C", + "headerText": "#F8F8F2", + "inactiveSelection": "#44475A80", + "inserted": "#50FA7B26", + "modified": "#FFB86C4D", + "nullValue": "#6272A4", + "rowNumber": "#6272A4", + "selection": "#44475A", + "selectionText": "#F8F8F2", + "text": "#F8F8F2" }, - "ui": { - "windowBackground": "#282A36", - "controlBackground": "#343746", - "cardBackground": "#21222C", - "border": "#44475A", - "primaryText": "#F8F8F2", - "secondaryText": "#BFC0C9", - "tertiaryText": "#6272A4", - "accentColor": "#BD93F9", - "selectionBackground": "#44475A", - "hoverBackground": "#44475A80", - "status": { - "success": "#50FA7B", - "warning": "#FFB86C", - "error": "#FF5555", - "info": "#8BE9FD" - }, - "badges": { - "background": "#44475A", - "primaryKey": "#8BE9FD26", - "autoIncrement": "#BD93F926" - } - }, - "sidebar": { - "background": "#21222C", - "text": "#F8F8F2", - "selectedItem": "#44475A", - "hover": "#343746", - "sectionHeader": "#6272A4" + "editor": { + "background": "#282A36", + "currentLine": "#44475A", + "currentStatement": "#FFFFFF0F", + "cursor": "#F8F8F2", + "invisibles": "#424450", + "lineNumber": "#6272A4", + "selection": "#44475A", + "syntax": { + "comment": "#6272A4", + "function": "#50FA7B", + "keyword": "#FF79C6", + "null": "#FFB86C", + "number": "#BD93F9", + "operator": "#FF79C6", + "string": "#F1FA8C", + "type": "#8BE9FD" + }, + "text": "#F8F8F2" }, - "toolbar": { - "secondaryText": "#BFC0C9", - "tertiaryText": "#6272A4" + "panel": { + "background": "#282A36", + "controlBackground": "#343746", + "secondaryText": "#BFC0C9", + "separator": "#44475A", + "tertiaryText": "#6272A4", + "text": "#F8F8F2" }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 + "status": { + "error": "#FF5555", + "success": "#50FA7B", + "warning": "#FFB86C" } + }, + "id": "tablepro.dracula", + "name": "Dracula", + "schema": 2 } diff --git a/TablePro/Resources/Themes/tablepro.nord.json b/TablePro/Resources/Themes/tablepro.nord.json index 884cb69034..7448128435 100644 --- a/TablePro/Resources/Themes/tablepro.nord.json +++ b/TablePro/Resources/Themes/tablepro.nord.json @@ -1,81 +1,62 @@ { - "id": "tablepro.nord", - "name": "Nord", - "version": 1, - "appearance": "dark", - "author": "TablePro", - "editor": { - "background": "#2E3440", - "text": "#D8DEE9", - "cursor": "#88C0D0", - "currentLineHighlight": "#3B4252", - "currentStatementHighlight": "#FFFFFF0F", - "selection": "#434C5E", - "lineNumber": "#4C566A", - "invisibles": "#3B4252", - "syntax": { - "keyword": "#81A1C1", - "string": "#A3BE8C", - "number": "#B48EAD", - "comment": "#616E88", - "null": "#D08770", - "operator": "#81A1C1", - "function": "#88C0D0", - "type": "#EBCB8B" - } - }, + "appearance": "dark", + "author": "TablePro", + "content": { "dataGrid": { - "background": "#2E3440", - "text": "#D8DEE9", - "alternateRow": "#3B4252", - "nullValue": "#4C566A", - "boolTrue": "#A3BE8C", - "boolFalse": "#BF616A", - "rowNumber": "#4C566A", - "modified": "#EBCB8B4D", - "inserted": "#A3BE8C26", - "deleted": "#BF616A26", - "deletedText": "#BF616A80", - "focusBorder": "#88C0D0" + "alternateRow": "#3B4252", + "background": "#2E3440", + "boolFalse": "#BF616A", + "boolTrue": "#A3BE8C", + "deleted": "#BF616A26", + "deletedText": "#BF616A80", + "focusBorder": "#88C0D0", + "gridLine": "#434C5E", + "headerBackground": "#3B4252", + "headerText": "#D8DEE9", + "inactiveSelection": "#434C5E80", + "inserted": "#A3BE8C26", + "modified": "#EBCB8B4D", + "nullValue": "#4C566A", + "rowNumber": "#4C566A", + "selection": "#434C5E", + "selectionText": "#ECEFF4", + "text": "#D8DEE9" }, - "ui": { - "windowBackground": "#2E3440", - "controlBackground": "#3B4252", - "cardBackground": "#3B4252", - "border": "#434C5E", - "primaryText": "#D8DEE9", - "secondaryText": "#9DA5B4", - "tertiaryText": "#616E88", - "accentColor": "#88C0D0", - "selectionBackground": "#434C5E", - "hoverBackground": "#88C0D00D", - "status": { - "success": "#A3BE8C", - "warning": "#D08770", - "error": "#BF616A", - "info": "#5E81AC" - }, - "badges": { - "background": "#434C5E", - "primaryKey": "#5E81AC26", - "autoIncrement": "#B48EAD26" - } - }, - "sidebar": { - "background": "#3B4252", - "text": "#D8DEE9", - "selectedItem": "#434C5E", - "hover": "#434C5E80", - "sectionHeader": "#4C566A" + "editor": { + "background": "#2E3440", + "currentLine": "#3B4252", + "currentStatement": "#FFFFFF0F", + "cursor": "#88C0D0", + "invisibles": "#3B4252", + "lineNumber": "#4C566A", + "selection": "#434C5E", + "syntax": { + "comment": "#616E88", + "function": "#88C0D0", + "keyword": "#81A1C1", + "null": "#D08770", + "number": "#B48EAD", + "operator": "#81A1C1", + "string": "#A3BE8C", + "type": "#EBCB8B" + }, + "text": "#D8DEE9" }, - "toolbar": { - "secondaryText": "#9DA5B4", - "tertiaryText": "#616E88" + "panel": { + "background": "#2E3440", + "controlBackground": "#3B4252", + "secondaryText": "#9DA5B4", + "separator": "#434C5E", + "tertiaryText": "#616E88", + "text": "#D8DEE9" }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 + "status": { + "error": "#BF616A", + "success": "#A3BE8C", + "warning": "#D08770" } + }, + "id": "tablepro.nord", + "name": "Nord", + "schema": 2 } diff --git a/TablePro/Theme/BuiltInThemes.swift b/TablePro/Theme/BuiltInThemes.swift new file mode 100644 index 0000000000..560c37bd84 --- /dev/null +++ b/TablePro/Theme/BuiltInThemes.swift @@ -0,0 +1,142 @@ +import Foundation + +/// Default Light and Default Dark are Swift, not JSON: they are the fallback every other theme is +/// measured against, so they cannot themselves be a file that fails to load. Their surround slots +/// stay on `system:` values, which keeps the unthemed app pixel-identical to before the theme +/// owned these surfaces and keeps the system's Increase Contrast and vibrancy adaptations. +internal enum BuiltInThemes { + internal static let defaultLightId = "tablepro.default-light" + internal static let defaultDarkId = "tablepro.default-dark" + + internal static let light = ThemeDefinition( + id: defaultLightId, + name: String(localized: "Default Light"), + author: "TablePro", + appearance: .light, + editor: EditorThemeColors( + background: .hex("#FFFFFF"), + text: .hex("#000000"), + cursor: .hex("#007AFF"), + selection: .hex("#B4D8FD"), + currentLine: .hex("#007AFF14"), + currentStatement: .hex("#0A0A0A0F"), + lineNumber: .hex("#8E8E93"), + invisibles: .hex("#C7C7CC"), + syntax: SyntaxThemeColors( + keyword: .hex("#0A49A5"), + string: .hex("#C41A16"), + number: .hex("#6C36A9"), + comment: .hex("#007400"), + null: .hex("#C55B00"), + operator: .hex("#000000"), + function: .hex("#326D74"), + type: .hex("#3F6E74") + ) + ), + dataGrid: systemDataGrid( + modified: "#FFD60A4D", + inserted: "#34C7594D", + deleted: "#FF3B304D", + deletedText: "#FF3B3080" + ), + panel: .systemPanel, + status: StatusThemeColors( + success: .hex("#248A3D"), + warning: .hex("#C55B00"), + error: .hex("#D70015") + ) + ) + + internal static let dark = ThemeDefinition( + id: defaultDarkId, + name: String(localized: "Default Dark"), + author: "TablePro", + appearance: .dark, + editor: EditorThemeColors( + background: .hex("#1E1E1E"), + text: .hex("#D4D4D4"), + cursor: .hex("#007AFF"), + selection: .hex("#264F78"), + currentLine: .hex("#007AFF14"), + currentStatement: .hex("#FFFFFF0F"), + lineNumber: .hex("#858585"), + invisibles: .hex("#4D4D4D"), + syntax: SyntaxThemeColors( + keyword: .hex("#569CD6"), + string: .hex("#CE9178"), + number: .hex("#B5CEA8"), + comment: .hex("#6A9955"), + null: .hex("#FF8C00"), + operator: .hex("#D4D4D4"), + function: .hex("#DCDCAA"), + type: .hex("#4EC9B0") + ) + ), + dataGrid: systemDataGrid( + modified: "#FFD60A4D", + inserted: "#32D74B26", + deleted: "#FF453A26", + deletedText: "#FF453A80" + ), + panel: .systemPanel, + status: StatusThemeColors( + success: .hex("#32D74B"), + warning: .hex("#FF9F0A"), + error: .hex("#FF453A") + ) + ) + + internal static let all: [ThemeDefinition] = [light, dark] + + internal static func `default`(for appearance: ThemeAppearance) -> ThemeDefinition { + switch appearance { + case .light: return light + case .dark: return dark + } + } + + internal static func defaultId(for appearance: ThemeAppearance) -> String { + `default`(for: appearance).id + } + + private static func systemDataGrid( + modified: String, + inserted: String, + deleted: String, + deletedText: String + ) -> DataGridThemeColors { + DataGridThemeColors( + background: .system(.controlBackground), + text: .system(.label), + alternateRow: .system(.alternatingContentBackgroundOdd), + headerBackground: .system(.windowBackground), + headerText: .system(.label), + gridLine: .system(.grid), + selection: .system(.selectedContentBackground), + selectionText: .system(.alternateSelectedControlText), + inactiveSelection: .system(.unemphasizedSelectedContentBackground), + focusBorder: .system(.keyboardFocusIndicator), + nullValue: .system(.secondaryLabel), + boolTrue: .system(.label), + boolFalse: .system(.label), + rowNumber: .system(.secondaryLabel), + modified: .hex(modified), + inserted: .hex(inserted), + deleted: .hex(deleted), + deletedText: .hex(deletedText) + ) + } +} + +internal extension PanelThemeColors { + /// The semantic colours these surfaces already named before the theme owned them, so a default + /// theme is invisible against the unthemed app and keeps the system's contrast handling. + static let systemPanel = PanelThemeColors( + background: .system(.controlBackground), + controlBackground: .system(.textBackground), + text: .system(.label), + secondaryText: .system(.secondaryLabel), + tertiaryText: .system(.tertiaryLabel), + separator: .system(.separator) + ) +} diff --git a/TablePro/Theme/HexColor.swift b/TablePro/Theme/HexColor.swift deleted file mode 100644 index 5378d3acc9..0000000000 --- a/TablePro/Theme/HexColor.swift +++ /dev/null @@ -1,68 +0,0 @@ -import AppKit -import SwiftUI - -extension String { - var nsColor: NSColor { - let hex = trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "#")) - - let hexLength = (hex as NSString).length - guard hexLength == 6 || hexLength == 8 else { - return .labelColor - } - - var value: UInt64 = 0 - guard Scanner(string: hex).scanHexInt64(&value) else { - return .labelColor - } - - let r, g, b, a: CGFloat - - if hexLength == 8 { - r = CGFloat((value >> 24) & 0xFF) / 255.0 - g = CGFloat((value >> 16) & 0xFF) / 255.0 - b = CGFloat((value >> 8) & 0xFF) / 255.0 - a = CGFloat(value & 0xFF) / 255.0 - } else { - r = CGFloat((value >> 16) & 0xFF) / 255.0 - g = CGFloat((value >> 8) & 0xFF) / 255.0 - b = CGFloat(value & 0xFF) / 255.0 - a = 1.0 - } - - return NSColor(srgbRed: r, green: g, blue: b, alpha: a) - } - - var swiftUIColor: Color { - Color(nsColor: nsColor) - } - - var cgColor: CGColor { - nsColor.cgColor - } -} - -extension NSColor { - var hexString: String { - guard let converted = usingColorSpace(.sRGB) else { - return "#808080" - } - - var r: CGFloat = 0 - var g: CGFloat = 0 - var b: CGFloat = 0 - var a: CGFloat = 0 - converted.getRed(&r, green: &g, blue: &b, alpha: &a) - - let ri = Int(round(r * 255)) - let gi = Int(round(g * 255)) - let bi = Int(round(b * 255)) - - if a < 1.0 { - let ai = Int(round(a * 255)) - return String(format: "#%02X%02X%02X%02X", ri, gi, bi, ai) - } - - return String(format: "#%02X%02X%02X", ri, gi, bi) - } -} diff --git a/TablePro/Theme/ResolvedThemeColors.swift b/TablePro/Theme/ResolvedThemeColors.swift deleted file mode 100644 index 1c6dff20a2..0000000000 --- a/TablePro/Theme/ResolvedThemeColors.swift +++ /dev/null @@ -1,275 +0,0 @@ -import AppKit -import SwiftUI - -struct ResolvedEditorColors { - let background: NSColor - let backgroundSwiftUI: Color - let text: NSColor - let textSwiftUI: Color - let cursor: NSColor - let cursorSwiftUI: Color - let currentLineHighlight: NSColor - let currentLineHighlightSwiftUI: Color - let currentStatementHighlight: NSColor - let selection: NSColor - let selectionSwiftUI: Color - let lineNumber: NSColor - let lineNumberSwiftUI: Color - let invisibles: NSColor - let invisiblesSwiftUI: Color - - let keyword: NSColor - let keywordSwiftUI: Color - let string: NSColor - let stringSwiftUI: Color - let number: NSColor - let numberSwiftUI: Color - let comment: NSColor - let commentSwiftUI: Color - let null: NSColor - let nullSwiftUI: Color - let `operator`: NSColor - let operatorSwiftUI: Color - let function: NSColor - let functionSwiftUI: Color - let type: NSColor - let typeSwiftUI: Color - - init(from colors: EditorThemeColors) { - background = colors.background.nsColor - backgroundSwiftUI = colors.background.swiftUIColor - text = colors.text.nsColor - textSwiftUI = colors.text.swiftUIColor - cursor = colors.cursor.nsColor - cursorSwiftUI = colors.cursor.swiftUIColor - currentLineHighlight = colors.currentLineHighlight.nsColor - currentLineHighlightSwiftUI = colors.currentLineHighlight.swiftUIColor - currentStatementHighlight = colors.currentStatementHighlight.nsColor - selection = colors.selection.nsColor - selectionSwiftUI = colors.selection.swiftUIColor - lineNumber = colors.lineNumber.nsColor - lineNumberSwiftUI = colors.lineNumber.swiftUIColor - invisibles = colors.invisibles.nsColor - invisiblesSwiftUI = colors.invisibles.swiftUIColor - - keyword = colors.syntax.keyword.nsColor - keywordSwiftUI = colors.syntax.keyword.swiftUIColor - string = colors.syntax.string.nsColor - stringSwiftUI = colors.syntax.string.swiftUIColor - number = colors.syntax.number.nsColor - numberSwiftUI = colors.syntax.number.swiftUIColor - comment = colors.syntax.comment.nsColor - commentSwiftUI = colors.syntax.comment.swiftUIColor - null = colors.syntax.null.nsColor - nullSwiftUI = colors.syntax.null.swiftUIColor - `operator` = colors.syntax.operator.nsColor - operatorSwiftUI = colors.syntax.operator.swiftUIColor - function = colors.syntax.function.nsColor - functionSwiftUI = colors.syntax.function.swiftUIColor - type = colors.syntax.type.nsColor - typeSwiftUI = colors.syntax.type.swiftUIColor - } -} - -struct ResolvedDataGridColors { - let background: NSColor - let backgroundSwiftUI: Color - let text: NSColor - let textSwiftUI: Color - let alternateRow: NSColor - let alternateRowSwiftUI: Color - let nullValue: NSColor - let nullValueSwiftUI: Color - let boolTrue: NSColor - let boolTrueSwiftUI: Color - let boolFalse: NSColor - let boolFalseSwiftUI: Color - let rowNumber: NSColor - let rowNumberSwiftUI: Color - - let modified: NSColor - let modifiedSwiftUI: Color - let modifiedCG: CGColor - let inserted: NSColor - let insertedSwiftUI: Color - let insertedCG: CGColor - let deleted: NSColor - let deletedSwiftUI: Color - let deletedCG: CGColor - let deletedText: NSColor - let deletedTextSwiftUI: Color - - let focusBorder: NSColor - let focusBorderCG: CGColor - - init(from colors: DataGridThemeColors) { - background = colors.background.nsColor - backgroundSwiftUI = colors.background.swiftUIColor - text = colors.text.nsColor - textSwiftUI = colors.text.swiftUIColor - alternateRow = colors.alternateRow.nsColor - alternateRowSwiftUI = colors.alternateRow.swiftUIColor - nullValue = colors.nullValue.nsColor - nullValueSwiftUI = colors.nullValue.swiftUIColor - boolTrue = colors.boolTrue.nsColor - boolTrueSwiftUI = colors.boolTrue.swiftUIColor - boolFalse = colors.boolFalse.nsColor - boolFalseSwiftUI = colors.boolFalse.swiftUIColor - rowNumber = colors.rowNumber.nsColor - rowNumberSwiftUI = colors.rowNumber.swiftUIColor - - modified = colors.modified.nsColor - modifiedSwiftUI = colors.modified.swiftUIColor - modifiedCG = colors.modified.cgColor - inserted = colors.inserted.nsColor - insertedSwiftUI = colors.inserted.swiftUIColor - insertedCG = colors.inserted.cgColor - deleted = colors.deleted.nsColor - deletedSwiftUI = colors.deleted.swiftUIColor - deletedCG = colors.deleted.cgColor - deletedText = colors.deletedText.nsColor - deletedTextSwiftUI = colors.deletedText.swiftUIColor - - focusBorder = colors.focusBorder.nsColor - focusBorderCG = colors.focusBorder.cgColor - } -} - -struct ResolvedUIColors { - let windowBackground: NSColor - let windowBackgroundSwiftUI: Color - let controlBackground: NSColor - let controlBackgroundSwiftUI: Color - let cardBackground: NSColor - let cardBackgroundSwiftUI: Color - let border: NSColor - let borderSwiftUI: Color - - let primaryText: NSColor - let primaryTextSwiftUI: Color - let secondaryText: NSColor - let secondaryTextSwiftUI: Color - let tertiaryText: NSColor - let tertiaryTextSwiftUI: Color - - let selectionBackground: NSColor - let selectionBackgroundSwiftUI: Color - let hoverBackground: NSColor - let hoverBackgroundSwiftUI: Color - - let success: NSColor - let successSwiftUI: Color - let warning: NSColor - let warningSwiftUI: Color - let error: NSColor - let errorSwiftUI: Color - let info: NSColor - let infoSwiftUI: Color - - let badgeBackground: NSColor - let badgeBackgroundSwiftUI: Color - let badgePrimaryKey: NSColor - let badgePrimaryKeySwiftUI: Color - let badgeAutoIncrement: NSColor - let badgeAutoIncrementSwiftUI: Color - - init(from colors: UIThemeColors) { - windowBackground = colors.windowBackground?.nsColor ?? .windowBackgroundColor - windowBackgroundSwiftUI = colors.windowBackground?.swiftUIColor ?? Color(nsColor: .windowBackgroundColor) - controlBackground = colors.controlBackground?.nsColor ?? .controlBackgroundColor - controlBackgroundSwiftUI = colors.controlBackground?.swiftUIColor - ?? Color(nsColor: .controlBackgroundColor) - cardBackground = colors.cardBackground?.nsColor ?? .controlBackgroundColor - cardBackgroundSwiftUI = colors.cardBackground?.swiftUIColor ?? Color(nsColor: .controlBackgroundColor) - border = colors.border?.nsColor ?? .separatorColor - borderSwiftUI = colors.border?.swiftUIColor ?? Color(nsColor: .separatorColor) - - primaryText = colors.primaryText?.nsColor ?? .labelColor - primaryTextSwiftUI = colors.primaryText?.swiftUIColor ?? Color(nsColor: .labelColor) - secondaryText = colors.secondaryText?.nsColor ?? .secondaryLabelColor - secondaryTextSwiftUI = colors.secondaryText?.swiftUIColor ?? Color(nsColor: .secondaryLabelColor) - tertiaryText = colors.tertiaryText?.nsColor ?? .tertiaryLabelColor - tertiaryTextSwiftUI = colors.tertiaryText?.swiftUIColor ?? Color(nsColor: .tertiaryLabelColor) - - selectionBackground = colors.selectionBackground?.nsColor ?? .selectedContentBackgroundColor - selectionBackgroundSwiftUI = colors.selectionBackground?.swiftUIColor - ?? Color(nsColor: .selectedContentBackgroundColor) - hoverBackground = colors.hoverBackground?.nsColor ?? .unemphasizedSelectedContentBackgroundColor - hoverBackgroundSwiftUI = colors.hoverBackground?.swiftUIColor - ?? Color(nsColor: .unemphasizedSelectedContentBackgroundColor) - - success = colors.status.success.nsColor - successSwiftUI = colors.status.success.swiftUIColor - warning = colors.status.warning.nsColor - warningSwiftUI = colors.status.warning.swiftUIColor - error = colors.status.error.nsColor - errorSwiftUI = colors.status.error.swiftUIColor - info = colors.status.info.nsColor - infoSwiftUI = colors.status.info.swiftUIColor - - badgeBackground = colors.badges.background.nsColor - badgeBackgroundSwiftUI = colors.badges.background.swiftUIColor - badgePrimaryKey = colors.badges.primaryKey.nsColor - badgePrimaryKeySwiftUI = colors.badges.primaryKey.swiftUIColor - badgeAutoIncrement = colors.badges.autoIncrement.nsColor - badgeAutoIncrementSwiftUI = colors.badges.autoIncrement.swiftUIColor - } -} - -struct ResolvedSidebarColors { - let background: NSColor - let backgroundSwiftUI: Color - let text: NSColor - let textSwiftUI: Color - let selectedItem: NSColor - let selectedItemSwiftUI: Color - let hover: NSColor - let hoverSwiftUI: Color - let sectionHeader: NSColor - let sectionHeaderSwiftUI: Color - - init(from colors: SidebarThemeColors) { - background = colors.background?.nsColor ?? .windowBackgroundColor - backgroundSwiftUI = colors.background?.swiftUIColor ?? Color(nsColor: .windowBackgroundColor) - text = colors.text?.nsColor ?? .labelColor - textSwiftUI = colors.text?.swiftUIColor ?? Color(nsColor: .labelColor) - selectedItem = colors.selectedItem?.nsColor ?? .selectedContentBackgroundColor - selectedItemSwiftUI = colors.selectedItem?.swiftUIColor - ?? Color(nsColor: .selectedContentBackgroundColor) - hover = colors.hover?.nsColor ?? .unemphasizedSelectedContentBackgroundColor - hoverSwiftUI = colors.hover?.swiftUIColor - ?? Color(nsColor: .unemphasizedSelectedContentBackgroundColor) - sectionHeader = colors.sectionHeader?.nsColor ?? .secondaryLabelColor - sectionHeaderSwiftUI = colors.sectionHeader?.swiftUIColor ?? Color(nsColor: .secondaryLabelColor) - } -} - -struct ResolvedToolbarColors { - let secondaryText: NSColor - let secondaryTextSwiftUI: Color - let tertiaryText: NSColor - let tertiaryTextSwiftUI: Color - - init(from colors: ToolbarThemeColors) { - secondaryText = colors.secondaryText?.nsColor ?? .secondaryLabelColor - secondaryTextSwiftUI = colors.secondaryText?.swiftUIColor ?? Color(nsColor: .secondaryLabelColor) - tertiaryText = colors.tertiaryText?.nsColor ?? .tertiaryLabelColor - tertiaryTextSwiftUI = colors.tertiaryText?.swiftUIColor ?? Color(nsColor: .tertiaryLabelColor) - } -} - -struct ResolvedThemeColors { - let editor: ResolvedEditorColors - let dataGrid: ResolvedDataGridColors - let ui: ResolvedUIColors - let sidebar: ResolvedSidebarColors - let toolbar: ResolvedToolbarColors - - init(from theme: ThemeDefinition) { - editor = ResolvedEditorColors(from: theme.editor) - dataGrid = ResolvedDataGridColors(from: theme.dataGrid) - ui = ResolvedUIColors(from: theme.ui) - sidebar = ResolvedSidebarColors(from: theme.sidebar) - toolbar = ResolvedToolbarColors(from: theme.toolbar) - } -} diff --git a/TablePro/Theme/ThemeCatalog.swift b/TablePro/Theme/ThemeCatalog.swift new file mode 100644 index 0000000000..81562859da --- /dev/null +++ b/TablePro/Theme/ThemeCatalog.swift @@ -0,0 +1,284 @@ +import Foundation +import os + +internal struct ThemeCatalogContents: Equatable, Sendable { + internal var themes: [ThemeDefinition] + internal var rejected: [RejectedThemeRecord] + + internal static let empty = ThemeCatalogContents(themes: BuiltInThemes.all, rejected: []) +} + +internal struct RejectedThemeRecord: Equatable, Sendable { + internal let path: String + internal let fileName: String + internal let reason: String +} + +internal enum ThemeOrigin: Sendable { + case bundle + case registry + case user + + internal var allowedPrefix: String? { + switch self { + case .bundle: return ThemeDefinition.builtInPrefix + case .registry: return ThemeDefinition.registryPrefix + case .user: return nil + } + } +} + +/// Owns the list. Loading is off the main actor; every mutation updates the in-memory list +/// synchronously before anything activates, because the old storage refreshed through a detached +/// Task and a save was therefore followed by an activation of the stale copy. +@MainActor +@Observable +internal final class ThemeCatalog { + internal static let shared = ThemeCatalog() + + internal private(set) var themes: [ThemeDefinition] = BuiltInThemes.all + internal private(set) var rejected: [RejectedThemeRecord] = [] + + @ObservationIgnored + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeCatalog") + + @ObservationIgnored + nonisolated private static let bundledOrder = [ + BuiltInThemes.defaultLightId, + BuiltInThemes.defaultDarkId, + "tablepro.dracula", + "tablepro.nord", + ] + + /// Loaded before the first activation, not after it. The old catalog refreshed through a + /// detached Task, so the launch activation ran against built-ins alone and a user theme in a + /// slot resolved to the default until something else re-activated it. + private init() { + reloadSynchronously() + } + + internal func theme(id: String) -> ThemeDefinition? { + themes.first { $0.id == id } + } + + internal func reload() async { + let contents = await Task.detached { ThemeCatalog.loadContents() }.value + themes = contents.themes + rejected = contents.rejected + } + + internal func reloadSynchronously() { + let contents = Self.loadContents() + themes = contents.themes + rejected = contents.rejected + } + + internal func save(_ theme: ThemeDefinition) throws { + guard theme.isEditable else { throw ThemeLoadError.reservedIdentifier(theme.id) } + try Self.write(theme, to: Self.userDirectory) + upsert(theme) + } + + internal func delete(id: String) throws { + guard !id.hasPrefix(ThemeDefinition.builtInPrefix), !id.hasPrefix(ThemeDefinition.registryPrefix) else { + throw ThemeLoadError.reservedIdentifier(id) + } + try Self.remove(id: id, from: Self.userDirectory) + themes.removeAll { $0.id == id } + } + + internal func saveRegistryTheme(_ theme: ThemeDefinition) throws { + try Self.write(theme, to: Self.registryDirectory) + upsert(theme) + } + + internal func deleteRegistryTheme(id: String) throws { + try Self.remove(id: id, from: Self.registryDirectory) + themes.removeAll { $0.id == id } + rejected.removeAll { $0.fileName == "\(id).json" } + } + + internal func importTheme(from url: URL) throws -> ThemeDefinition { + let document = try ThemeDocument(data: try Data(contentsOf: url)) + var theme = document.resolved() + + if !theme.isEditable || containsTheme(id: theme.id) { + theme.id = ThemeIdentifier.generated() + } + + try Self.write(theme, to: Self.userDirectory) + upsert(theme) + return theme + } + + internal func exportTheme(_ theme: ThemeDefinition, to url: URL) throws { + try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) + } + + internal func loadRegistryMeta() -> RegistryThemeMeta { + let url = Self.registryMetaURL + guard FileManager.default.fileExists(atPath: url.path) else { return RegistryThemeMeta() } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(RegistryThemeMeta.self, from: try Data(contentsOf: url)) + } catch { + Self.logger.error("Failed to load registry meta: \(error)") + return RegistryThemeMeta() + } + } + + internal func saveRegistryMeta(_ meta: RegistryThemeMeta) throws { + Self.ensureDirectory(Self.registryDirectory) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(meta).write(to: Self.registryMetaURL, options: .atomic) + } + + private func containsTheme(id: String) -> Bool { + themes.contains { $0.id == id } + } + + private func upsert(_ theme: ThemeDefinition) { + if let index = themes.firstIndex(where: { $0.id == theme.id }) { + themes[index] = theme + return + } + themes.append(theme) + } + + // MARK: - Locations + + nonisolated private static let userDirectory: URL = + AppStorageEnvironment.shared.applicationSupportRoot + .appendingPathComponent("TablePro/Themes", isDirectory: true) + + nonisolated private static let registryDirectory: URL = + userDirectory.appendingPathComponent("Registry", isDirectory: true) + + nonisolated private static let registryMetaURL: URL = + registryDirectory.appendingPathComponent("registry-meta.json") + + // MARK: - Disk + + nonisolated private static func loadContents() -> ThemeCatalogContents { + var themes = BuiltInThemes.all + var rejected: [RejectedThemeRecord] = [] + + ensureDirectory(userDirectory) + ensureDirectory(registryDirectory) + + let sources: [(URL, ThemeOrigin)] = [ + (Bundle.main.resourceURL, .bundle), + (registryDirectory, .registry), + (userDirectory, .user), + ].compactMap { url, source in url.map { ($0, source) } } + + for (directory, source) in sources { + let loaded = load(from: directory, source: source) + rejected.append(contentsOf: loaded.rejected) + + for theme in loaded.themes { + if let index = themes.firstIndex(where: { $0.id == theme.id }) { + themes[index] = theme + continue + } + themes.append(theme) + } + } + + themes.sort { lhs, rhs in + let left = bundledOrder.firstIndex(of: lhs.id) ?? Int.max + let right = bundledOrder.firstIndex(of: rhs.id) ?? Int.max + guard left == right else { return left < right } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + + return ThemeCatalogContents(themes: themes, rejected: rejected) + } + + nonisolated private static func load( + from directory: URL, + source: ThemeOrigin + ) -> (themes: [ThemeDefinition], rejected: [RejectedThemeRecord]) { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: directory.path) else { return ([], []) } + + let files: [URL] + do { + files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" && $0.lastPathComponent != "registry-meta.json" } + } catch { + logger.error("Failed to list \(directory.lastPathComponent): \(error)") + return ([], []) + } + + var themes: [ThemeDefinition] = [] + var rejected: [RejectedThemeRecord] = [] + + for file in files { + if source == .bundle, !file.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) { continue } + + do { + let document = try ThemeDocument(data: try Data(contentsOf: file)) + try validate(identifier: document.id, from: source) + themes.append(document.resolved()) + } catch { + guard source != .bundle else { + logger.error("Bundled theme \(file.lastPathComponent) rejected: \(error.localizedDescription)") + continue + } + rejected.append( + RejectedThemeRecord( + path: file.path, + fileName: file.lastPathComponent, + reason: error.localizedDescription + ) + ) + } + } + + return (themes, rejected) + } + + /// A reserved prefix is accepted only from the directory that owns it. A file dropped into the + /// user folder claiming `tablepro.` or `registry.` would otherwise shadow the real theme, be + /// read-only because the prefix says built-in, and be impossible to delete. + nonisolated private static func validate(identifier: String, from source: ThemeOrigin) throws { + guard ThemeIdentifier.isValid(identifier) else { + throw ThemeLoadError.invalidIdentifier(identifier) + } + + let reserved = [ThemeDefinition.builtInPrefix, ThemeDefinition.registryPrefix] + for prefix in reserved where identifier.hasPrefix(prefix) { + guard source.allowedPrefix == prefix else { + throw ThemeLoadError.reservedIdentifier(identifier) + } + } + } + + nonisolated private static func write(_ theme: ThemeDefinition, to directory: URL) throws { + ensureDirectory(directory) + guard ThemeIdentifier.isValid(theme.id) else { throw ThemeLoadError.invalidIdentifier(theme.id) } + let url = directory.appendingPathComponent("\(theme.id).json", isDirectory: false) + try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) + } + + nonisolated private static func remove(id: String, from directory: URL) throws { + guard ThemeIdentifier.isValid(id) else { throw ThemeLoadError.invalidIdentifier(id) } + let url = directory.appendingPathComponent("\(id).json", isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + nonisolated private static func ensureDirectory(_ url: URL) { + let fileManager = FileManager.default + guard !fileManager.fileExists(atPath: url.path) else { return } + do { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } catch { + logger.error("Failed to create \(url.lastPathComponent): \(error)") + } + } +} diff --git a/TablePro/Theme/ThemeColorValue.swift b/TablePro/Theme/ThemeColorValue.swift new file mode 100644 index 0000000000..2a446ea2f6 --- /dev/null +++ b/TablePro/Theme/ThemeColorValue.swift @@ -0,0 +1,194 @@ +import AppKit +import SwiftUI + +internal enum SystemColorName: String, Codable, CaseIterable, Sendable { + case label + case secondaryLabel + case tertiaryLabel + case quaternaryLabel + case text + case textBackground + case selectedText + case selectedTextBackground + case placeholderText + case disabledControlText + case controlBackground + case windowBackground + case underPageBackground + case separator + case grid + case selectedContentBackground + case unemphasizedSelectedContentBackground + case alternateSelectedControlText + case controlAccent + case keyboardFocusIndicator + case alternatingContentBackgroundEven + case alternatingContentBackgroundOdd + case systemRed + case systemGreen + case systemBlue + case systemOrange + case systemYellow + case systemPurple + case systemTeal + case systemGray + + internal var color: NSColor { + switch self { + case .label: return .labelColor + case .secondaryLabel: return .secondaryLabelColor + case .tertiaryLabel: return .tertiaryLabelColor + case .quaternaryLabel: return .quaternaryLabelColor + case .text: return .textColor + case .textBackground: return .textBackgroundColor + case .selectedText: return .selectedTextColor + case .selectedTextBackground: return .selectedTextBackgroundColor + case .placeholderText: return .placeholderTextColor + case .disabledControlText: return .disabledControlTextColor + case .controlBackground: return .controlBackgroundColor + case .windowBackground: return .windowBackgroundColor + case .underPageBackground: return .underPageBackgroundColor + case .separator: return .separatorColor + case .grid: return .gridColor + case .selectedContentBackground: return .selectedContentBackgroundColor + case .unemphasizedSelectedContentBackground: return .unemphasizedSelectedContentBackgroundColor + case .alternateSelectedControlText: return .alternateSelectedControlTextColor + case .controlAccent: return .controlAccentColor + case .keyboardFocusIndicator: return .keyboardFocusIndicatorColor + case .alternatingContentBackgroundEven: return Self.alternatingContentBackground(at: 0) + case .alternatingContentBackgroundOdd: return Self.alternatingContentBackground(at: 1) + case .systemRed: return .systemRed + case .systemGreen: return .systemGreen + case .systemBlue: return .systemBlue + case .systemOrange: return .systemOrange + case .systemYellow: return .systemYellow + case .systemPurple: return .systemPurple + case .systemTeal: return .systemTeal + case .systemGray: return .systemGray + } + } + + private static func alternatingContentBackground(at index: Int) -> NSColor { + let colors = NSColor.alternatingContentBackgroundColors + guard colors.indices.contains(index) else { return .controlBackgroundColor } + return colors[index] + } +} + +/// A theme slot's value. `system` keeps a slot on the AppKit semantic colour its call site used +/// before the theme owned it, so the built-in themes stay pixel-identical to the unthemed app and +/// keep the system's own Increase Contrast and vibrancy adaptations. A custom theme uses `hex`. +internal enum ThemeColorValue: Equatable, Hashable, Sendable { + case hex(String) + case system(SystemColorName) + + internal static let systemPrefix = "system:" + + internal init(validating raw: String) throws { + if raw.hasPrefix(Self.systemPrefix) { + let name = String(raw.dropFirst(Self.systemPrefix.count)) + guard let known = SystemColorName(rawValue: name) else { + throw ThemeLoadError.unknownSystemColor(name) + } + self = .system(known) + return + } + + guard let canonical = HexColor.canonicalize(raw) else { + throw ThemeLoadError.invalidColor(raw) + } + self = .hex(canonical) + } + + internal var rawValue: String { + switch self { + case let .hex(value): return value + case let .system(name): return Self.systemPrefix + name.rawValue + } + } + + internal var nsColor: NSColor { + switch self { + case let .hex(value): return HexColor.color(value) + case let .system(name): return name.color + } + } + + internal var swiftUIColor: Color { + Color(nsColor: nsColor) + } + + internal var isSystem: Bool { + if case .system = self { return true } + return false + } +} + +extension ThemeColorValue: Codable { + internal init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + try self.init(validating: raw) + } + + internal func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// The parser rejects any string it does not consume whole. The previous one asked only whether +/// `Scanner.scanHexInt64` succeeded, which it does on a valid prefix, so `#FF79CG` silently +/// rendered as `#0FF79C` instead of reporting a typo. +internal enum HexColor { + internal static func canonicalize(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("#") else { return nil } + + let digits = trimmed.dropFirst() + guard digits.count == 6 || digits.count == 8 else { return nil } + guard digits.allSatisfy(\.isHexDigit) else { return nil } + + return "#" + digits.uppercased() + } + + internal static func color(_ canonical: String) -> NSColor { + let digits = canonical.dropFirst() + var value: UInt64 = 0 + guard Scanner(string: String(digits)).scanHexInt64(&value) else { return .labelColor } + + if digits.count == 8 { + return NSColor( + srgbRed: CGFloat((value >> 24) & 0xFF) / 255, + green: CGFloat((value >> 16) & 0xFF) / 255, + blue: CGFloat((value >> 8) & 0xFF) / 255, + alpha: CGFloat(value & 0xFF) / 255 + ) + } + + return NSColor( + srgbRed: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: 1 + ) + } + + internal static func string(from color: NSColor) -> String { + guard let converted = color.usingColorSpace(.sRGB) else { return "#808080" } + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + converted.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let redValue = Int(round(red * 255)) + let greenValue = Int(round(green * 255)) + let blueValue = Int(round(blue * 255)) + + guard alpha < 1 else { + return String(format: "#%02X%02X%02X", redValue, greenValue, blueValue) + } + return String(format: "#%02X%02X%02X%02X", redValue, greenValue, blueValue, Int(round(alpha * 255))) + } +} diff --git a/TablePro/Theme/ThemeColors.swift b/TablePro/Theme/ThemeColors.swift deleted file mode 100644 index 7fbb9bddd7..0000000000 --- a/TablePro/Theme/ThemeColors.swift +++ /dev/null @@ -1,402 +0,0 @@ -// -// ThemeColors.swift -// TablePro -// - -import Foundation -import SwiftUI -// MARK: - Syntax Colors - -internal struct SyntaxColors: Codable, Equatable, Sendable { - var keyword: String - var string: String - var number: String - var comment: String - var null: String - var `operator`: String - var function: String - var type: String - - static let defaultLight = SyntaxColors( - keyword: "#0A49A5", - string: "#C41A16", - number: "#6C36A9", - comment: "#007400", - null: "#C55B00", - operator: "#000000", - function: "#326D74", - type: "#3F6E74" - ) - - init( - keyword: String, - string: String, - number: String, - comment: String, - null: String, - operator: String, - function: String, - type: String - ) { - self.keyword = keyword - self.string = string - self.number = number - self.comment = comment - self.null = null - self.operator = `operator` - self.function = function - self.type = type - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = SyntaxColors.defaultLight - - keyword = try container.decodeIfPresent(String.self, forKey: .keyword) ?? fallback.keyword - string = try container.decodeIfPresent(String.self, forKey: .string) ?? fallback.string - number = try container.decodeIfPresent(String.self, forKey: .number) ?? fallback.number - comment = try container.decodeIfPresent(String.self, forKey: .comment) ?? fallback.comment - null = try container.decodeIfPresent(String.self, forKey: .null) ?? fallback.null - `operator` = try container.decodeIfPresent(String.self, forKey: .operator) ?? fallback.operator - function = try container.decodeIfPresent(String.self, forKey: .function) ?? fallback.function - type = try container.decodeIfPresent(String.self, forKey: .type) ?? fallback.type - } -} - -// MARK: - Editor Theme Colors - -internal struct EditorThemeColors: Codable, Equatable, Sendable { - var background: String - var text: String - var cursor: String - var currentLineHighlight: String - var selection: String - var lineNumber: String - var invisibles: String - var currentStatementHighlight: String - var syntax: SyntaxColors - - static let defaultLight = EditorThemeColors( - background: "#FFFFFF", - text: "#000000", - cursor: "#007AFF", - currentLineHighlight: "#007AFF14", - selection: "#B4D8FD", - lineNumber: "#8E8E93", - invisibles: "#C7C7CC", - currentStatementHighlight: "#0A0A0A0F", - syntax: .defaultLight - ) - - init( - background: String, - text: String, - cursor: String, - currentLineHighlight: String, - selection: String, - lineNumber: String, - invisibles: String, - currentStatementHighlight: String, - syntax: SyntaxColors - ) { - self.background = background - self.text = text - self.cursor = cursor - self.currentLineHighlight = currentLineHighlight - self.selection = selection - self.lineNumber = lineNumber - self.invisibles = invisibles - self.currentStatementHighlight = currentStatementHighlight - self.syntax = syntax - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = EditorThemeColors.defaultLight - - background = try container.decodeIfPresent(String.self, forKey: .background) ?? fallback.background - text = try container.decodeIfPresent(String.self, forKey: .text) ?? fallback.text - cursor = try container.decodeIfPresent(String.self, forKey: .cursor) ?? fallback.cursor - currentLineHighlight = try container.decodeIfPresent(String.self, forKey: .currentLineHighlight) - ?? fallback.currentLineHighlight - selection = try container.decodeIfPresent(String.self, forKey: .selection) ?? fallback.selection - lineNumber = try container.decodeIfPresent(String.self, forKey: .lineNumber) ?? fallback.lineNumber - invisibles = try container.decodeIfPresent(String.self, forKey: .invisibles) ?? fallback.invisibles - currentStatementHighlight = try container.decodeIfPresent(String.self, forKey: .currentStatementHighlight) - ?? fallback.currentStatementHighlight - syntax = try container.decodeIfPresent(SyntaxColors.self, forKey: .syntax) ?? fallback.syntax - } -} - -// MARK: - Data Grid Theme Colors - -internal struct DataGridThemeColors: Codable, Equatable, Sendable { - var background: String - var text: String - var alternateRow: String - var nullValue: String - var boolTrue: String - var boolFalse: String - var rowNumber: String - var modified: String - var inserted: String - var deleted: String - var deletedText: String - var focusBorder: String - - static let defaultLight = DataGridThemeColors( - background: "#FFFFFF", - text: "#000000", - alternateRow: "#F5F5F5", - nullValue: "#8E8E93", - boolTrue: "#248A3D", - boolFalse: "#D70015", - rowNumber: "#8E8E93", - modified: "#FFD60A4D", - inserted: "#34C7594D", - deleted: "#FF3B304D", - deletedText: "#FF3B3080", - focusBorder: "#007AFF" - ) - - init( - background: String, - text: String, - alternateRow: String, - nullValue: String, - boolTrue: String, - boolFalse: String, - rowNumber: String, - modified: String, - inserted: String, - deleted: String, - deletedText: String, - focusBorder: String - ) { - self.background = background - self.text = text - self.alternateRow = alternateRow - self.nullValue = nullValue - self.boolTrue = boolTrue - self.boolFalse = boolFalse - self.rowNumber = rowNumber - self.modified = modified - self.inserted = inserted - self.deleted = deleted - self.deletedText = deletedText - self.focusBorder = focusBorder - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = DataGridThemeColors.defaultLight - - background = try container.decodeIfPresent(String.self, forKey: .background) ?? fallback.background - text = try container.decodeIfPresent(String.self, forKey: .text) ?? fallback.text - alternateRow = try container.decodeIfPresent(String.self, forKey: .alternateRow) ?? fallback.alternateRow - nullValue = try container.decodeIfPresent(String.self, forKey: .nullValue) ?? fallback.nullValue - boolTrue = try container.decodeIfPresent(String.self, forKey: .boolTrue) ?? fallback.boolTrue - boolFalse = try container.decodeIfPresent(String.self, forKey: .boolFalse) ?? fallback.boolFalse - rowNumber = try container.decodeIfPresent(String.self, forKey: .rowNumber) ?? fallback.rowNumber - modified = try container.decodeIfPresent(String.self, forKey: .modified) ?? fallback.modified - inserted = try container.decodeIfPresent(String.self, forKey: .inserted) ?? fallback.inserted - deleted = try container.decodeIfPresent(String.self, forKey: .deleted) ?? fallback.deleted - deletedText = try container.decodeIfPresent(String.self, forKey: .deletedText) ?? fallback.deletedText - focusBorder = try container.decodeIfPresent(String.self, forKey: .focusBorder) ?? fallback.focusBorder - } -} - -// MARK: - Status Colors - -internal struct StatusColors: Codable, Equatable, Sendable { - var success: String - var warning: String - var error: String - var info: String - - static let defaultLight = StatusColors( - success: "#248A3D", - warning: "#C55B00", - error: "#D70015", - info: "#007AFF" - ) - - init(success: String, warning: String, error: String, info: String) { - self.success = success - self.warning = warning - self.error = error - self.info = info - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = StatusColors.defaultLight - - success = try container.decodeIfPresent(String.self, forKey: .success) ?? fallback.success - warning = try container.decodeIfPresent(String.self, forKey: .warning) ?? fallback.warning - error = try container.decodeIfPresent(String.self, forKey: .error) ?? fallback.error - info = try container.decodeIfPresent(String.self, forKey: .info) ?? fallback.info - } -} - -// MARK: - Badge Colors - -internal struct BadgeColors: Codable, Equatable, Sendable { - var background: String - var primaryKey: String - var autoIncrement: String - - static let defaultLight = BadgeColors( - background: "#E5E5EA", - primaryKey: "#007AFF26", - autoIncrement: "#AF52DE26" - ) - - init(background: String, primaryKey: String, autoIncrement: String) { - self.background = background - self.primaryKey = primaryKey - self.autoIncrement = autoIncrement - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = BadgeColors.defaultLight - - background = try container.decodeIfPresent(String.self, forKey: .background) ?? fallback.background - primaryKey = try container.decodeIfPresent(String.self, forKey: .primaryKey) ?? fallback.primaryKey - autoIncrement = try container.decodeIfPresent(String.self, forKey: .autoIncrement) ?? fallback.autoIncrement - } -} - -// MARK: - UI Theme Colors - -internal struct UIThemeColors: Codable, Equatable, Sendable { - var windowBackground: String? - var controlBackground: String? - var cardBackground: String? - var border: String? - var primaryText: String? - var secondaryText: String? - var tertiaryText: String? - var selectionBackground: String? - var hoverBackground: String? - var status: StatusColors - var badges: BadgeColors - - static let defaultLight = UIThemeColors( - windowBackground: nil, - controlBackground: nil, - cardBackground: nil, - border: nil, - primaryText: nil, - secondaryText: nil, - tertiaryText: nil, - selectionBackground: nil, - hoverBackground: nil, - status: .defaultLight, - badges: .defaultLight - ) - - init( - windowBackground: String?, - controlBackground: String?, - cardBackground: String?, - border: String?, - primaryText: String?, - secondaryText: String?, - tertiaryText: String?, - selectionBackground: String?, - hoverBackground: String?, - status: StatusColors, - badges: BadgeColors - ) { - self.windowBackground = windowBackground - self.controlBackground = controlBackground - self.cardBackground = cardBackground - self.border = border - self.primaryText = primaryText - self.secondaryText = secondaryText - self.tertiaryText = tertiaryText - self.selectionBackground = selectionBackground - self.hoverBackground = hoverBackground - self.status = status - self.badges = badges - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = UIThemeColors.defaultLight - - windowBackground = try container.decodeIfPresent(String.self, forKey: .windowBackground) - controlBackground = try container.decodeIfPresent(String.self, forKey: .controlBackground) - cardBackground = try container.decodeIfPresent(String.self, forKey: .cardBackground) - border = try container.decodeIfPresent(String.self, forKey: .border) - primaryText = try container.decodeIfPresent(String.self, forKey: .primaryText) - secondaryText = try container.decodeIfPresent(String.self, forKey: .secondaryText) - tertiaryText = try container.decodeIfPresent(String.self, forKey: .tertiaryText) - selectionBackground = try container.decodeIfPresent(String.self, forKey: .selectionBackground) - hoverBackground = try container.decodeIfPresent(String.self, forKey: .hoverBackground) - status = try container.decodeIfPresent(StatusColors.self, forKey: .status) ?? fallback.status - badges = try container.decodeIfPresent(BadgeColors.self, forKey: .badges) ?? fallback.badges - } -} - -// MARK: - Sidebar Theme Colors - -internal struct SidebarThemeColors: Codable, Equatable, Sendable { - var background: String? - var text: String? - var selectedItem: String? - var hover: String? - var sectionHeader: String? - - static let defaultLight = SidebarThemeColors( - background: nil, - text: nil, - selectedItem: nil, - hover: nil, - sectionHeader: nil - ) - - init(background: String?, text: String?, selectedItem: String?, hover: String?, sectionHeader: String?) { - self.background = background - self.text = text - self.selectedItem = selectedItem - self.hover = hover - self.sectionHeader = sectionHeader - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - background = try container.decodeIfPresent(String.self, forKey: .background) - text = try container.decodeIfPresent(String.self, forKey: .text) - selectedItem = try container.decodeIfPresent(String.self, forKey: .selectedItem) - hover = try container.decodeIfPresent(String.self, forKey: .hover) - sectionHeader = try container.decodeIfPresent(String.self, forKey: .sectionHeader) - } -} - -// MARK: - Toolbar Theme Colors - -internal struct ToolbarThemeColors: Codable, Equatable, Sendable { - var secondaryText: String? - var tertiaryText: String? - - static let defaultLight = ToolbarThemeColors( - secondaryText: nil, - tertiaryText: nil - ) - - init(secondaryText: String?, tertiaryText: String?) { - self.secondaryText = secondaryText - self.tertiaryText = tertiaryText - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - secondaryText = try container.decodeIfPresent(String.self, forKey: .secondaryText) - tertiaryText = try container.decodeIfPresent(String.self, forKey: .tertiaryText) - } -} diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index f0eaddf405..39cddb54c6 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -1,80 +1,287 @@ -import SwiftUI +import Foundation -internal struct ThemeDefinition: Codable, Identifiable, Equatable, Sendable { +internal enum ThemeAppearance: String, Codable, CaseIterable, Sendable { + case light + case dark +} + +internal struct SyntaxThemeColors: Equatable, Sendable { + var keyword: ThemeColorValue + var string: ThemeColorValue + var number: ThemeColorValue + var comment: ThemeColorValue + var null: ThemeColorValue + var `operator`: ThemeColorValue + var function: ThemeColorValue + var type: ThemeColorValue +} + +internal struct EditorThemeColors: Equatable, Sendable { + var background: ThemeColorValue + var text: ThemeColorValue + var cursor: ThemeColorValue + var selection: ThemeColorValue + var currentLine: ThemeColorValue + var currentStatement: ThemeColorValue + var lineNumber: ThemeColorValue + var invisibles: ThemeColorValue + var syntax: SyntaxThemeColors +} + +internal struct DataGridThemeColors: Equatable, Sendable { + var background: ThemeColorValue + var text: ThemeColorValue + var alternateRow: ThemeColorValue + var headerBackground: ThemeColorValue + var headerText: ThemeColorValue + var gridLine: ThemeColorValue + var selection: ThemeColorValue + var selectionText: ThemeColorValue + var inactiveSelection: ThemeColorValue + var focusBorder: ThemeColorValue + var nullValue: ThemeColorValue + var boolTrue: ThemeColorValue + var boolFalse: ThemeColorValue + var rowNumber: ThemeColorValue + var modified: ThemeColorValue + var inserted: ThemeColorValue + var deleted: ThemeColorValue + var deletedText: ThemeColorValue +} + +internal struct StatusThemeColors: Equatable, Sendable { + var success: ThemeColorValue + var warning: ThemeColorValue + var error: ThemeColorValue +} + +internal struct PanelThemeColors: Equatable, Sendable { + var background: ThemeColorValue + var controlBackground: ThemeColorValue + var text: ThemeColorValue + var secondaryText: ThemeColorValue + var tertiaryText: ThemeColorValue + var separator: ThemeColorValue +} + +internal struct ThemeDefinition: Identifiable, Equatable, Sendable { var id: String var name: String - var version: Int - var appearance: ThemeAppearance var author: String + var appearance: ThemeAppearance var editor: EditorThemeColors var dataGrid: DataGridThemeColors - var ui: UIThemeColors - var sidebar: SidebarThemeColors - var toolbar: ToolbarThemeColors - var fonts: ThemeFonts - - var isBuiltIn: Bool { id.hasPrefix("tablepro.") } - var isRegistry: Bool { id.hasPrefix("registry.") } - var isEditable: Bool { !isBuiltIn && !isRegistry } - - static let `default` = ThemeDefinition( - id: "tablepro.default-light", - name: "Default Light", - version: 1, - appearance: .light, - author: "TablePro", - editor: .defaultLight, - dataGrid: .defaultLight, - ui: .defaultLight, - sidebar: .defaultLight, - toolbar: .defaultLight, - fonts: .default - ) - - init( - id: String, - name: String, - version: Int, - appearance: ThemeAppearance, - author: String, - editor: EditorThemeColors, - dataGrid: DataGridThemeColors, - ui: UIThemeColors, - sidebar: SidebarThemeColors, - toolbar: ToolbarThemeColors, - fonts: ThemeFonts - ) { - self.id = id - self.name = name - self.version = version - self.appearance = appearance - self.author = author - self.editor = editor - self.dataGrid = dataGrid - self.ui = ui - self.sidebar = sidebar - self.toolbar = toolbar - self.fonts = fonts + var panel: PanelThemeColors + var status: StatusThemeColors + + internal static let builtInPrefix = "tablepro." + internal static let registryPrefix = "registry." + internal static let userPrefix = "user." + + internal var isBuiltIn: Bool { id.hasPrefix(Self.builtInPrefix) } + internal var isRegistry: Bool { id.hasPrefix(Self.registryPrefix) } + internal var isEditable: Bool { !isBuiltIn && !isRegistry } +} + +/// The single registry every slot goes through. A slot exists here because a call site reads it, +/// which is what `ThemeSlotCoverageTests` enforces: the previous schema let whole groups +/// ship with no reader at all, and nothing caught it. The gate, the theme editor and the document +/// decoder all enumerate this list rather than keeping their own copies. +internal enum ThemeSlot: String, CaseIterable, Sendable { + case editorBackground = "content.editor.background" + case editorText = "content.editor.text" + case editorCursor = "content.editor.cursor" + case editorSelection = "content.editor.selection" + case editorCurrentLine = "content.editor.currentLine" + case editorCurrentStatement = "content.editor.currentStatement" + case editorLineNumber = "content.editor.lineNumber" + case editorInvisibles = "content.editor.invisibles" + + case syntaxKeyword = "content.editor.syntax.keyword" + case syntaxString = "content.editor.syntax.string" + case syntaxNumber = "content.editor.syntax.number" + case syntaxComment = "content.editor.syntax.comment" + case syntaxNull = "content.editor.syntax.null" + case syntaxOperator = "content.editor.syntax.operator" + case syntaxFunction = "content.editor.syntax.function" + case syntaxType = "content.editor.syntax.type" + + case gridBackground = "content.dataGrid.background" + case gridText = "content.dataGrid.text" + case gridAlternateRow = "content.dataGrid.alternateRow" + case gridHeaderBackground = "content.dataGrid.headerBackground" + case gridHeaderText = "content.dataGrid.headerText" + case gridLine = "content.dataGrid.gridLine" + case gridSelection = "content.dataGrid.selection" + case gridSelectionText = "content.dataGrid.selectionText" + case gridInactiveSelection = "content.dataGrid.inactiveSelection" + case gridFocusBorder = "content.dataGrid.focusBorder" + case gridNullValue = "content.dataGrid.nullValue" + case gridBoolTrue = "content.dataGrid.boolTrue" + case gridBoolFalse = "content.dataGrid.boolFalse" + case gridRowNumber = "content.dataGrid.rowNumber" + case gridModified = "content.dataGrid.modified" + case gridInserted = "content.dataGrid.inserted" + case gridDeleted = "content.dataGrid.deleted" + case gridDeletedText = "content.dataGrid.deletedText" + + case panelBackground = "content.panel.background" + case panelControlBackground = "content.panel.controlBackground" + case panelText = "content.panel.text" + case panelSecondaryText = "content.panel.secondaryText" + case panelTertiaryText = "content.panel.tertiaryText" + case panelSeparator = "content.panel.separator" + + case statusSuccess = "content.status.success" + case statusWarning = "content.status.warning" + case statusError = "content.status.error" + + + internal var since: Int { 2 } + + internal var group: ThemeSlotGroup { + switch self { + case .editorBackground, .editorText, .editorCursor, .editorSelection, + .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles: + return .editor + case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: + return .syntax + case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, + .gridLine, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, + .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, + .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + return .dataGrid + case .panelBackground, .panelControlBackground, .panelText, + .panelSecondaryText, .panelTertiaryText, .panelSeparator: + return .panel + case .statusSuccess, .statusWarning, .statusError: + return .status + } + } + + internal var keyPath: WritableKeyPath { + switch self { + case .editorBackground: return \.editor.background + case .editorText: return \.editor.text + case .editorCursor: return \.editor.cursor + case .editorSelection: return \.editor.selection + case .editorCurrentLine: return \.editor.currentLine + case .editorCurrentStatement: return \.editor.currentStatement + case .editorLineNumber: return \.editor.lineNumber + case .editorInvisibles: return \.editor.invisibles + + case .syntaxKeyword: return \.editor.syntax.keyword + case .syntaxString: return \.editor.syntax.string + case .syntaxNumber: return \.editor.syntax.number + case .syntaxComment: return \.editor.syntax.comment + case .syntaxNull: return \.editor.syntax.null + case .syntaxOperator: return \.editor.syntax.operator + case .syntaxFunction: return \.editor.syntax.function + case .syntaxType: return \.editor.syntax.type + + case .gridBackground: return \.dataGrid.background + case .gridText: return \.dataGrid.text + case .gridAlternateRow: return \.dataGrid.alternateRow + case .gridHeaderBackground: return \.dataGrid.headerBackground + case .gridHeaderText: return \.dataGrid.headerText + case .gridLine: return \.dataGrid.gridLine + case .gridSelection: return \.dataGrid.selection + case .gridSelectionText: return \.dataGrid.selectionText + case .gridInactiveSelection: return \.dataGrid.inactiveSelection + case .gridFocusBorder: return \.dataGrid.focusBorder + case .gridNullValue: return \.dataGrid.nullValue + case .gridBoolTrue: return \.dataGrid.boolTrue + case .gridBoolFalse: return \.dataGrid.boolFalse + case .gridRowNumber: return \.dataGrid.rowNumber + case .gridModified: return \.dataGrid.modified + case .gridInserted: return \.dataGrid.inserted + case .gridDeleted: return \.dataGrid.deleted + case .gridDeletedText: return \.dataGrid.deletedText + + case .panelBackground: return \.panel.background + case .panelControlBackground: return \.panel.controlBackground + case .panelText: return \.panel.text + case .panelSecondaryText: return \.panel.secondaryText + case .panelTertiaryText: return \.panel.tertiaryText + case .panelSeparator: return \.panel.separator + + case .statusSuccess: return \.status.success + case .statusWarning: return \.status.warning + case .statusError: return \.status.error + + } + } + + internal var label: String { + switch self { + case .editorBackground, .gridBackground: return String(localized: "Background") + case .editorText, .gridText: return String(localized: "Text") + case .editorCursor: return String(localized: "Cursor") + case .editorSelection, .gridSelection: return String(localized: "Selection") + case .editorCurrentLine: return String(localized: "Current Line") + case .editorCurrentStatement: return String(localized: "Current Statement") + case .editorLineNumber: return String(localized: "Line Number") + case .editorInvisibles: return String(localized: "Invisibles") + case .syntaxKeyword: return String(localized: "Keyword") + case .syntaxString: return String(localized: "String") + case .syntaxNumber: return String(localized: "Number") + case .syntaxComment: return String(localized: "Comment") + case .syntaxNull: return String(localized: "NULL") + case .syntaxOperator: return String(localized: "Operator") + case .syntaxFunction: return String(localized: "Function") + case .syntaxType: return String(localized: "Type") + case .gridAlternateRow: return String(localized: "Alternate Row") + case .gridHeaderBackground: return String(localized: "Header Background") + case .gridHeaderText: return String(localized: "Header Text") + case .gridLine: return String(localized: "Grid Line") + case .gridSelectionText: return String(localized: "Selected Text") + case .gridInactiveSelection: return String(localized: "Inactive Selection") + case .gridFocusBorder: return String(localized: "Focus Border") + case .gridNullValue: return String(localized: "NULL Value") + case .gridBoolTrue: return String(localized: "Bool True") + case .gridBoolFalse: return String(localized: "Bool False") + case .gridRowNumber: return String(localized: "Row Number") + case .gridModified: return String(localized: "Modified") + case .gridInserted: return String(localized: "Inserted") + case .gridDeleted: return String(localized: "Deleted") + case .gridDeletedText: return String(localized: "Deleted Text") + case .statusSuccess: return String(localized: "Success") + case .statusWarning: return String(localized: "Warning") + case .statusError: return String(localized: "Error") + case .panelBackground: return String(localized: "Pane Background") + case .panelControlBackground: return String(localized: "Field Background") + case .panelText: return String(localized: "Pane Text") + case .panelSecondaryText: return String(localized: "Secondary Text") + case .panelTertiaryText: return String(localized: "Tertiary Text") + case .panelSeparator: return String(localized: "Separator") + } + } +} + +internal enum ThemeSlotGroup: String, CaseIterable, Sendable { + case editor + case syntax + case dataGrid + case panel + case status + + internal var label: String { + switch self { + case .editor: return String(localized: "Editor") + case .syntax: return String(localized: "Syntax Colors") + case .dataGrid: return String(localized: "Data Grid") + case .panel: return String(localized: "Panels") + case .status: return String(localized: "Status") + } } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = ThemeDefinition.default - - id = try container.decodeIfPresent(String.self, forKey: .id) ?? fallback.id - name = try container.decodeIfPresent(String.self, forKey: .name) ?? fallback.name - version = try container.decodeIfPresent(Int.self, forKey: .version) ?? fallback.version - appearance = try container.decodeIfPresent(ThemeAppearance.self, forKey: .appearance) ?? fallback.appearance - author = try container.decodeIfPresent(String.self, forKey: .author) ?? fallback.author - editor = try container.decodeIfPresent(EditorThemeColors.self, forKey: .editor) ?? fallback.editor - dataGrid = try container.decodeIfPresent(DataGridThemeColors.self, forKey: .dataGrid) ?? fallback.dataGrid - ui = try container.decodeIfPresent(UIThemeColors.self, forKey: .ui) ?? fallback.ui - sidebar = try container.decodeIfPresent(SidebarThemeColors.self, forKey: .sidebar) ?? fallback.sidebar - toolbar = try container.decodeIfPresent(ToolbarThemeColors.self, forKey: .toolbar) ?? fallback.toolbar - fonts = try container.decodeIfPresent(ThemeFonts.self, forKey: .fonts) ?? fallback.fonts + internal var slots: [ThemeSlot] { + ThemeSlot.allCases.filter { $0.group == self } } } -internal enum ThemeAppearance: String, Codable, Sendable { - case light, dark, auto +internal enum ThemeSchema { + internal static let current = 2 + internal static let oldestSupported = 2 } diff --git a/TablePro/Theme/ThemeDocument.swift b/TablePro/Theme/ThemeDocument.swift new file mode 100644 index 0000000000..4debffd57a --- /dev/null +++ b/TablePro/Theme/ThemeDocument.swift @@ -0,0 +1,200 @@ +import Foundation + +internal indirect enum ThemeJSONNode: Decodable { + case string(String) + case number(Int) + case object([String: ThemeJSONNode]) + + internal init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode(Int.self) { + self = .number(value) + return + } + self = .object(try container.decode([String: ThemeJSONNode].self)) + } + + internal var stringValue: String? { + if case let .string(value) = self { return value } + return nil + } + + internal var intValue: Int? { + if case let .number(value) = self { return value } + return nil + } + + internal func flattened(prefix: String = "") -> [String: String] { + switch self { + case let .string(value): + return prefix.isEmpty ? [:] : [prefix: value] + case .number: + return [:] + case let .object(children): + var result: [String: String] = [:] + for (key, child) in children { + let path = prefix.isEmpty ? key : "\(prefix).\(key)" + result.merge(child.flattened(prefix: path)) { _, new in new } + } + return result + } + } +} + +/// The theme file format, and the only place a file can be rejected. The previous decoder read +/// every key with `decodeIfPresent` and a Default Light fallback, so an empty object and a VS Code +/// theme both decoded "successfully" and rendered as Default Light under the file's own name. +internal struct ThemeDocument { + internal let schema: Int + internal let id: String + internal let name: String + internal let author: String + internal let appearance: ThemeAppearance + internal let colors: [ThemeSlot: ThemeColorValue] + + private enum MetadataKey: String, CaseIterable { + case schema + case id + case name + case author + case appearance + } + + internal init(data: Data) throws { + let root = try JSONDecoder().decode([String: ThemeJSONNode].self, from: data) + + guard let schema = root[MetadataKey.schema.rawValue]?.intValue else { + throw ThemeLoadError.missingSchema + } + guard schema >= ThemeSchema.oldestSupported else { + throw ThemeLoadError.schemaTooOld(found: schema, supported: ThemeSchema.current) + } + guard schema <= ThemeSchema.current else { + throw ThemeLoadError.schemaTooNew(found: schema, supported: ThemeSchema.current) + } + self.schema = schema + + guard let id = root[MetadataKey.id.rawValue]?.stringValue, !id.isEmpty else { + throw ThemeLoadError.missingField(MetadataKey.id.rawValue) + } + guard ThemeIdentifier.isValid(id) else { + throw ThemeLoadError.invalidIdentifier(id) + } + self.id = id + + guard let name = root[MetadataKey.name.rawValue]?.stringValue, !name.isEmpty else { + throw ThemeLoadError.missingField(MetadataKey.name.rawValue) + } + self.name = name + + guard + let appearanceRaw = root[MetadataKey.appearance.rawValue]?.stringValue, + let appearance = ThemeAppearance(rawValue: appearanceRaw) + else { + throw ThemeLoadError.missingField(MetadataKey.appearance.rawValue) + } + self.appearance = appearance + + author = root[MetadataKey.author.rawValue]?.stringValue ?? "" + + var flat: [String: String] = [:] + for (key, node) in root where MetadataKey(rawValue: key) == nil { + flat.merge(node.flattened(prefix: key)) { _, new in new } + } + + var parsed: [ThemeSlot: ThemeColorValue] = [:] + var unknown: [String] = [] + + for (path, raw) in flat { + guard let slot = ThemeSlot(rawValue: path) else { + unknown.append(path) + continue + } + parsed[slot] = try ThemeColorValue(validating: raw) + } + + guard unknown.isEmpty else { + throw ThemeLoadError.unknownKeys(unknown.sorted()) + } + + let missing = ThemeSlot.allCases + .filter { $0.since <= schema && parsed[$0] == nil } + .map(\.rawValue) + + guard missing.isEmpty else { + throw ThemeLoadError.missingSlots(missing.sorted()) + } + + colors = parsed + } + + /// A slot introduced after this file's schema revision takes the same-appearance built-in's + /// value for that exact slot. Every slot the file's own revision declares is required, so this + /// never papers over an omission the author could have made. + internal func resolved() -> ThemeDefinition { + var definition = BuiltInThemes.default(for: appearance) + definition.id = id + definition.name = name + definition.author = author + definition.appearance = appearance + + for slot in ThemeSlot.allCases { + guard let value = colors[slot] else { continue } + definition[keyPath: slot.keyPath] = value + } + + return definition + } +} + +internal enum ThemeIdentifier { + private static let pattern = #"^[A-Za-z0-9._-]+$"# + + internal static func isValid(_ id: String) -> Bool { + id.range(of: pattern, options: .regularExpression) != nil + && !id.contains("..") + } + + internal static func generated() -> String { + ThemeDefinition.userPrefix + UUID().uuidString.lowercased().prefix(8) + } +} + +internal enum ThemeEncoder { + internal static func data(for theme: ThemeDefinition) throws -> Data { + var root: [String: Any] = [ + "schema": ThemeSchema.current, + "id": theme.id, + "name": theme.name, + "author": theme.author, + "appearance": theme.appearance.rawValue, + ] + + for slot in ThemeSlot.allCases { + insert(theme[keyPath: slot.keyPath].rawValue, at: slot.rawValue.components(separatedBy: "."), into: &root) + } + + return try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + } + + private static func insert(_ value: String, at path: [String], into container: inout [String: Any]) { + guard let head = path.first else { return } + + guard path.count > 1 else { + container[head] = value + return + } + + var child = container[head] as? [String: Any] ?? [:] + insert(value, at: Array(path.dropFirst()), into: &child) + container[head] = child + } +} diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 165c5afdc1..89ead1b099 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -1,11 +1,3 @@ -// -// ThemeEngine.swift -// TablePro -// -// Central @Observable singleton managing the active theme. -// Replaces Theme.swift, SQLEditorTheme, DataGridFontCache, ToolbarDesignTokens. -// - import AppKit import CodeEditSourceEditor import Combine @@ -14,9 +6,6 @@ import Observation import os import SwiftUI -// MARK: - Font Caches - -/// Tags stored on NSTextField.tag to identify which font variant a cell uses. internal enum DataGridFontVariant { static let regular = 0 static let italic = 1 @@ -24,447 +13,263 @@ internal enum DataGridFontVariant { static let rowNumber = 3 } -internal struct EditorFontCache { - let font: NSFont - let lineNumberFont: NSFont - let scaleFactor: CGFloat +internal struct EditorFontCache: Equatable { + internal let font: NSFont + internal let lineNumberFont: NSFont + internal let scaleFactor: CGFloat - init(from fonts: ThemeFonts) { - let scale = Self.computeAccessibilityScale() + internal init(from typography: TypographySettings) { + let scale = Self.accessibilityScale() scaleFactor = scale - let scaledSize = round(CGFloat(min(max(fonts.editorFontSize, 11), 18)) * scale) - font = EditorFontResolver.resolve(familyId: fonts.editorFontFamily, size: scaledSize) - let lineNumSize = max(round((scaledSize - 2)), 9) - lineNumberFont = NSFont.monospacedSystemFont(ofSize: lineNumSize, weight: .regular) + + let size = round(CGFloat(typography.clampedEditorFontSize) * scale) + font = EditorFontResolver.resolve(familyId: typography.editorFontFamily, size: size) + lineNumberFont = NSFont.monospacedSystemFont(ofSize: max(round(size - 2), 9), weight: .regular) } - static func computeAccessibilityScale() -> CGFloat { - let preferredBodyFont = NSFont.preferredFont(forTextStyle: .body) - let scale = preferredBodyFont.pointSize / 13.0 - return min(max(scale, 0.5), 3.0) + internal static func accessibilityScale() -> CGFloat { + let preferred = NSFont.preferredFont(forTextStyle: .body) + return min(max(preferred.pointSize / 13.0, 0.5), 3.0) } } -internal struct DataGridFontCacheResolved { - let regular: NSFont - let italic: NSFont - let medium: NSFont - let rowNumber: NSFont - let monoCharWidth: CGFloat - - init(from fonts: ThemeFonts) { - let scale = EditorFontCache.computeAccessibilityScale() - let scaledSize = round(CGFloat(min(max(fonts.dataGridFontSize, 10), 18)) * scale) - regular = EditorFontResolver.resolve(familyId: fonts.dataGridFontFamily, size: scaledSize) +internal struct DataGridFontCache: Equatable { + internal let regular: NSFont + internal let italic: NSFont + internal let medium: NSFont + internal let rowNumber: NSFont + internal let monoCharWidth: CGFloat + + internal init(from typography: TypographySettings) { + let scale = EditorFontCache.accessibilityScale() + let size = round(CGFloat(typography.clampedDataGridFontSize) * scale) + + regular = EditorFontResolver.resolve(familyId: typography.dataGridFontFamily, size: size) italic = regular.withTraits(.italic) medium = NSFontManager.shared.convert(regular, toHaveTrait: .boldFontMask) - let rowNumSize = max(round(scaledSize - 1), 9) - rowNumber = NSFont.monospacedDigitSystemFont(ofSize: rowNumSize, weight: .regular) - let attrs: [NSAttributedString.Key: Any] = [.font: regular] - monoCharWidth = ("M" as NSString).size(withAttributes: attrs).width + rowNumber = NSFont.monospacedDigitSystemFont(ofSize: max(round(size - 1), 9), weight: .regular) + monoCharWidth = ("M" as NSString).size(withAttributes: [.font: regular]).width } } -// MARK: - ThemeEngine - +/// Owns the active palette and the font caches, and nothing else: the catalog is `ThemeCatalog`, +/// the choice is `ThemeResolver`, and the fonts come from settings. It never writes settings back, +/// so the flow is one way. @Observable @MainActor internal final class ThemeEngine { - static let shared = ThemeEngine() - - // MARK: - Active Theme - - private(set) var activeTheme: ThemeDefinition - - /// Pre-resolved colors (rebuilt on theme change) - private(set) var colors: ResolvedThemeColors - - /// Cached editor fonts - private(set) var editorFonts: EditorFontCache - - /// Cached data grid fonts - private(set) var dataGridFonts: DataGridFontCacheResolved + internal static let shared = ThemeEngine() - // MARK: - Stored Value Font + internal private(set) var pair: ThemePair + internal private(set) var effectiveAppearance: ThemeAppearance + internal private(set) var revision: Int + internal private(set) var palette: ThemePalette + internal private(set) var resolved: ResolvedTheme + internal private(set) var editorFonts: EditorFontCache + internal private(set) var dataGridFonts: DataGridFontCache - /// The font every control that shows or edits a stored value uses, so one value reads the same in - /// the grid cell, its inline editor, the row inspector and a pop-out window. It is the data grid - /// font today; a viewer that also wears the editor's colours and syntax palette takes - /// `editorFonts` instead. - var valueFont: NSFont { dataGridFonts.regular } + internal var activeTheme: ThemeDefinition { pair[effectiveAppearance] } - var valueFontSwiftUI: Font { Font(valueFont) } - - /// The emphasised variant, for the key half of a key/value pair. - var valueFontEmphasizedSwiftUI: Font { Font(dataGridFonts.medium) } - - // MARK: - Available Themes - - private(set) var availableThemes: [ThemeDefinition] - - // MARK: - Editor Behavioral Settings (read from AppSettingsManager) + internal var change: ThemeChange { + ThemeChange(revision: revision, appearance: effectiveAppearance) + } - /// These are not theme properties but are needed by makeEditorTheme() - @ObservationIgnored var highlightCurrentLine: Bool = true - @ObservationIgnored var highlightCurrentStatement: Bool = true - @ObservationIgnored var showLineNumbers: Bool = true - @ObservationIgnored var tabWidth: Int = 4 - @ObservationIgnored var wordWrap: Bool = false + /// Every control that shows or edits a stored value takes this, so one value reads the same in + /// the grid cell, its inline editor, the row inspector, a cell popover and a pop-out window. + internal var valueFont: NSFont { dataGridFonts.regular } + internal var valueFontSwiftUI: Font { Font(valueFont) } + internal var valueFontEmphasizedSwiftUI: Font { Font(dataGridFonts.medium) } - // MARK: - Private + @ObservationIgnored + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine") - @ObservationIgnored nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine") + @ObservationIgnored private var mode: AppAppearanceMode = .auto + @ObservationIgnored private var typography: TypographySettings = .default + @ObservationIgnored private var appearanceObservation: NSKeyValueObservation? @ObservationIgnored private var accessibilityObserver: NSObjectProtocol? - @ObservationIgnored private var lastAccessibilityScale: CGFloat = 1.0 - - // MARK: - Init + @ObservationIgnored private var lastAccessibilityScale: CGFloat = 1 private init() { - let theme = ThemeDefinition.default - - self.activeTheme = theme - self.colors = ResolvedThemeColors(from: theme) - self.editorFonts = EditorFontCache(from: theme.fonts) - self.dataGridFonts = DataGridFontCacheResolved(from: theme.fonts) - self.availableThemes = [theme] + pair = .builtIn + effectiveAppearance = .light + revision = 1 + palette = ThemePalette(revision: 1) + resolved = ResolvedTheme(definition: BuiltInThemes.light, appearance: .light) + editorFonts = EditorFontCache(from: .default) + dataGridFonts = DataGridFontCache(from: .default) observeAccessibilityChanges() - - Task { - let themes = await Task.detached { ThemeStorage.loadAllThemes() }.value - self.availableThemes = themes - } } - // MARK: - Theme Lifecycle + // MARK: - Settings entry points - func activateTheme(id: String) { - if let theme = availableThemes.first(where: { $0.id == id }) - ?? ThemeStorage.loadTheme(id: id) { - activateTheme(theme) - return - } - - Self.logger.warning("Theme '\(id)' not found; falling back to default") - activateTheme(.default) - } - - func activateTheme(_ theme: ThemeDefinition) { - activeTheme = theme - colors = ResolvedThemeColors(from: theme) - editorFonts = EditorFontCache(from: theme.fonts) - dataGridFonts = DataGridFontCacheResolved(from: theme.fonts) - - notifyThemeDidChange() - - Self.logger.info("Activated theme: \(theme.name) (\(theme.id))") - } - - // MARK: - Theme CRUD - - func saveUserTheme(_ theme: ThemeDefinition) throws { - try ThemeStorage.saveUserTheme(theme) - - if let index = availableThemes.firstIndex(where: { $0.id == theme.id }) { - availableThemes[index] = theme - } else { - availableThemes.append(theme) - } - reloadAvailableThemes() - - if theme.id == activeTheme.id { - activateTheme(theme) - } - } - - func deleteUserTheme(id: String) throws { - guard !id.hasPrefix("tablepro."), !id.hasPrefix("registry.") else { return } - try ThemeStorage.deleteUserTheme(id: id) - reloadAvailableThemes() - - // If deleted a preferred theme, reset that slot to default - var appearance = AppSettingsManager.shared.appearance - var changed = false - if id == appearance.preferredLightThemeId { - appearance.preferredLightThemeId = "tablepro.default-light" - changed = true - } - if id == appearance.preferredDarkThemeId { - appearance.preferredDarkThemeId = "tablepro.default-dark" - changed = true - } - if changed { - AppSettingsManager.shared.appearance = appearance - } else if id == activeTheme.id { - // Deleted a non-preferred but currently active theme — re-anchor to preferred - let appearance = AppSettingsManager.shared.appearance - updateAppearanceAndTheme( - mode: appearance.appearanceMode, - lightThemeId: appearance.preferredLightThemeId, - darkThemeId: appearance.preferredDarkThemeId - ) - } - } - - func duplicateTheme(_ theme: ThemeDefinition, newName: String) -> ThemeDefinition { - var copy = theme - copy.id = "user.\(UUID().uuidString.lowercased().prefix(8))" - copy.name = newName - copy.author = theme.author - return copy - } + internal func apply(mode: AppAppearanceMode, lightThemeId: String, darkThemeId: String) { + self.mode = mode + applyApplicationAppearance(mode) + updateSystemAppearanceObserver(mode) - func importTheme(from url: URL) throws -> ThemeDefinition { - let theme = try ThemeStorage.importTheme(from: url) - reloadAvailableThemes() - return theme - } + let selection = ThemeResolver.resolve( + mode: mode, + lightThemeId: lightThemeId, + darkThemeId: darkThemeId, + themes: ThemeCatalog.shared.themes, + systemIsDark: Self.systemIsDark() + ) - func exportTheme(_ theme: ThemeDefinition, to url: URL) throws { - try ThemeStorage.exportTheme(theme, to: url) + adopt(selection) } - var registryThemes: [ThemeDefinition] { - availableThemes.filter(\.isRegistry) + internal func apply(typography: TypographySettings) { + guard typography != self.typography else { return } + self.typography = typography + editorFonts = EditorFontCache(from: typography) + dataGridFonts = DataGridFontCache(from: typography) + bumpRevision() + publishChange() } - func uninstallRegistryTheme(registryPluginId: String) throws { - try ThemeRegistryInstaller.shared.uninstall(registryPluginId: registryPluginId) + /// Called when the catalog changes under a selection that is already live, so a saved edit is + /// visible without the settings round trip that used to re-activate a stale cached copy. + internal func reapply(lightThemeId: String, darkThemeId: String) { + apply(mode: mode, lightThemeId: lightThemeId, darkThemeId: darkThemeId) } - func reloadAvailableThemes() { - Task { - let themes = await Task.detached { ThemeStorage.loadAllThemes() }.value - self.availableThemes = themes - } - } + /// The resolver's output applied to the engine. Every entry point above funnels here, and it + /// is the seam a test uses to put a known pair in front of the grid without touching settings. + internal func adopt(_ selection: ThemeSelection) { + let appearanceChanged = selection.effectiveAppearance != effectiveAppearance + let pairChanged = selection.pair != pair - // MARK: - Editor Font Size Zoom + guard appearanceChanged || pairChanged else { return } - func adjustEditorFontSize(by delta: Int) { - var theme = activeTheme - let newSize = max(9, min(24, theme.fonts.editorFontSize + delta)) - guard newSize != theme.fonts.editorFontSize else { return } - theme.fonts.editorFontSize = newSize - activeTheme = theme - editorFonts = EditorFontCache(from: theme.fonts) - notifyThemeDidChange() + pair = selection.pair + effectiveAppearance = selection.effectiveAppearance + ThemeSource.shared.update(selection.pair) - // Persist so the zoom survives re-activation (e.g. system appearance change) - if theme.isEditable { - try? ThemeStorage.saveUserTheme(theme) + if pairChanged { + bumpRevision() } - } - - // MARK: - Font Cache Reload (accessibility) + resolved = ResolvedTheme(definition: activeTheme, appearance: effectiveAppearance) - func reloadFontCaches() { - editorFonts = EditorFontCache(from: activeTheme.fonts) - dataGridFonts = DataGridFontCacheResolved(from: activeTheme.fonts) - notifyThemeDidChange() + publishChange() + Self.logger.info("Theme \(self.activeTheme.id, privacy: .public) revision \(self.revision)") } - // MARK: - Update Editor Behavioral Settings - - func updateEditorSettings( - highlightCurrentLine: Bool, - highlightCurrentStatement: Bool, - showLineNumbers: Bool, - tabWidth: Int, - wordWrap: Bool - ) { - self.highlightCurrentLine = highlightCurrentLine - self.highlightCurrentStatement = highlightCurrentStatement - self.showLineNumbers = showLineNumbers - self.tabWidth = tabWidth - self.wordWrap = wordWrap + private func bumpRevision() { + revision += 1 + palette = ThemePalette(revision: revision) } - // MARK: - CodeEditSourceEditor Theme - - func makeEditorTheme() -> EditorTheme { - let c = colors.editor - - let textAttr = EditorTheme.Attribute(color: srgb(c.text)) - let commentAttr = EditorTheme.Attribute(color: srgb(c.comment)) - let keywordAttr = EditorTheme.Attribute(color: srgb(c.keyword), bold: true) - let stringAttr = EditorTheme.Attribute(color: srgb(c.string)) - let numberAttr = EditorTheme.Attribute(color: srgb(c.number)) - let variableAttr = EditorTheme.Attribute(color: srgb(c.null)) - let typeAttr = EditorTheme.Attribute(color: srgb(c.type)) - let operatorAttr = EditorTheme.Attribute(color: srgb(c.operator)) - let functionAttr = EditorTheme.Attribute(color: srgb(c.function)) - - let lineHighlight: NSColor = highlightCurrentLine ? c.currentLineHighlight : .clear - let statementHighlight: NSColor = highlightCurrentStatement ? resolvedStatementHighlight(c) : .clear - - return EditorTheme( - text: textAttr, - insertionPoint: srgb(c.cursor), - invisibles: EditorTheme.Attribute(color: srgb(c.invisibles)), - background: srgb(c.background), - lineHighlight: srgb(lineHighlight), - statementHighlight: srgb(statementHighlight), - selection: srgb(c.selection), - keywords: keywordAttr, - commands: keywordAttr, - types: typeAttr, - attributes: variableAttr, - variables: variableAttr, - values: variableAttr, - numbers: numberAttr, - strings: stringAttr, - characters: stringAttr, - comments: commentAttr, - operators: operatorAttr, - functions: functionAttr - ) - } - - /// The band's colour, corrected for a theme that never declared one. - /// - /// `EditorThemeColors` falls back to its light defaults for any key a theme omits, and every theme written before - /// this key existed omits it. On a dark custom theme that fallback is a near-black wash on a near-black - /// background: invisible, and indistinguishable from the feature being broken. Deriving the band from the - /// theme's own text colour instead is what the gutter glyph already does. - private func resolvedStatementHighlight(_ colors: ResolvedEditorColors) -> NSColor { - let declared = colors.currentStatementHighlight - let backgroundIsDark = (colors.background.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 1) < 0.5 - let bandIsDark = (declared.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 0) < 0.5 - guard backgroundIsDark, bandIsDark else { return declared } - return colors.text.withAlphaComponent(declared.alphaComponent) + private func publishChange() { + AppEvents.shared.themeChanged.send(change) } - // MARK: - Appearance - - @ObservationIgnored private(set) var appearanceMode: AppAppearanceMode = .auto - private(set) var effectiveAppearance: ThemeAppearance = .light - @ObservationIgnored private var currentLightThemeId: String = "tablepro.default-light" - @ObservationIgnored private var currentDarkThemeId: String = "tablepro.default-dark" - @ObservationIgnored private var systemAppearanceObservation: NSKeyValueObservation? - - /// Central entry point: resolves effective appearance, picks the correct theme, activates it, - /// and derives NSApp.appearance from the theme's own appearance metadata. - func updateAppearanceAndTheme( - mode: AppAppearanceMode, - lightThemeId: String, - darkThemeId: String - ) { - appearanceMode = mode - currentLightThemeId = lightThemeId - currentDarkThemeId = darkThemeId - - applyNSAppAppearance(mode: mode) - - let resolved = resolveEffectiveAppearance(mode) - effectiveAppearance = resolved - - let themeId = resolved == .dark ? darkThemeId : lightThemeId - activateTheme(id: themeId) - - updateSystemAppearanceObserver(mode: mode) - } + // MARK: - Application appearance - /// Resolve which appearance is in effect right now. - private func resolveEffectiveAppearance(_ mode: AppAppearanceMode) -> ThemeAppearance { + private func applyApplicationAppearance(_ mode: AppAppearanceMode) { switch mode { - case .light: return .light - case .dark: return .dark - case .auto: return systemIsDark() ? .dark : .light + case .light: NSApp?.appearance = NSAppearance(named: .aqua) + case .dark: NSApp?.appearance = NSAppearance(named: .darkAqua) + case .auto: NSApp?.appearance = nil } } - private func systemIsDark() -> Bool { - NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + private static func systemIsDark() -> Bool { + NSApp?.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } - /// Set NSApp.appearance based on the appearance mode (not the theme). - /// Auto mode sets nil so the system controls the chrome. - private func applyNSAppAppearance(mode: AppAppearanceMode) { - switch mode { - case .light: - NSApp?.appearance = NSAppearance(named: .aqua) - case .dark: - NSApp?.appearance = NSAppearance(named: .darkAqua) - case .auto: - NSApp?.appearance = nil - } - } - - // MARK: - System Appearance Observer - - private func updateSystemAppearanceObserver(mode: AppAppearanceMode) { - systemAppearanceObservation = nil - + /// KVO on `NSApplication.effectiveAppearance` is the channel Apple names in the + /// `NSControlTintDidChangeNotification` deprecation text. Only the static tier and the change + /// signal depend on it: the dynamic slot colours answer for the drawing appearance themselves. + private func updateSystemAppearanceObserver(_ mode: AppAppearanceMode) { + appearanceObservation = nil guard mode == .auto else { return } - systemAppearanceObservation = NSApp?.observe(\.effectiveAppearance) { [weak self] _, _ in + appearanceObservation = NSApp?.observe(\.effectiveAppearance) { [weak self] _, _ in Task { @MainActor [weak self] in - guard let self, self.appearanceMode == .auto else { return } - let newAppearance: ThemeAppearance = self.systemIsDark() ? .dark : .light - guard newAppearance != self.effectiveAppearance else { return } - self.effectiveAppearance = newAppearance - let themeId = newAppearance == .dark ? self.currentDarkThemeId : self.currentLightThemeId - self.activateTheme(id: themeId) + self?.systemAppearanceDidChange() } } } - // MARK: - Notifications + private func systemAppearanceDidChange() { + guard mode == .auto else { return } + let appearance: ThemeAppearance = Self.systemIsDark() ? .dark : .light + guard appearance != effectiveAppearance else { return } + + effectiveAppearance = appearance + resolved = ResolvedTheme(definition: activeTheme, appearance: appearance) + publishChange() + } + + // MARK: - CodeEditSourceEditor - private func notifyThemeDidChange() { - AppEvents.shared.themeChanged.send(()) + internal func makeEditorTheme() -> EditorTheme { + let editorSettings = AppSettingsManager.shared.editor + let text = EditorTheme.Attribute(color: resolved[.editorText]) + let comment = EditorTheme.Attribute(color: resolved[.syntaxComment]) + let keyword = EditorTheme.Attribute(color: resolved[.syntaxKeyword], bold: true) + let string = EditorTheme.Attribute(color: resolved[.syntaxString]) + let number = EditorTheme.Attribute(color: resolved[.syntaxNumber]) + let variable = EditorTheme.Attribute(color: resolved[.syntaxNull]) + let type = EditorTheme.Attribute(color: resolved[.syntaxType]) + let operatorAttribute = EditorTheme.Attribute(color: resolved[.syntaxOperator]) + let function = EditorTheme.Attribute(color: resolved[.syntaxFunction]) + + return EditorTheme( + text: text, + insertionPoint: resolved[.editorCursor], + invisibles: EditorTheme.Attribute(color: resolved[.editorInvisibles]), + background: resolved[.editorBackground], + lineHighlight: editorSettings.highlightCurrentLine ? resolved[.editorCurrentLine] : .clear, + statementHighlight: editorSettings.highlightCurrentStatement ? resolved[.editorCurrentStatement] : .clear, + selection: resolved[.editorSelection], + lineNumber: resolved[.editorLineNumber], + keywords: keyword, + commands: keyword, + types: type, + attributes: variable, + variables: variable, + values: variable, + numbers: number, + strings: string, + characters: string, + comments: comment, + operators: operatorAttribute, + functions: function + ) } // MARK: - Accessibility private func observeAccessibilityChanges() { - lastAccessibilityScale = EditorFontCache.computeAccessibilityScale() + lastAccessibilityScale = EditorFontCache.accessibilityScale() accessibilityObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in - guard let self else { return } - let newScale = EditorFontCache.computeAccessibilityScale() - guard abs(newScale - lastAccessibilityScale) > 0.01 else { return } - lastAccessibilityScale = newScale - Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))") - reloadFontCaches() - AppEvents.shared.accessibilityTextSizeChanged.send(()) + self?.accessibilityDisplayOptionsDidChange() } } } - // MARK: - Helpers + private func accessibilityDisplayOptionsDidChange() { + let scale = EditorFontCache.accessibilityScale() + guard abs(scale - lastAccessibilityScale) > 0.01 else { return } + lastAccessibilityScale = scale - private func srgb(_ color: NSColor) -> NSColor { - if let converted = color.usingColorSpace(.sRGB) { - return converted - } - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - if let deviceRgb = color.usingColorSpace(.deviceRGB) { - deviceRgb.getRed(&r, green: &g, blue: &b, alpha: &a) - } - return NSColor(srgbRed: r, green: g, blue: b, alpha: a) + editorFonts = EditorFontCache(from: typography) + dataGridFonts = DataGridFontCache(from: typography) + bumpRevision() + publishChange() + AppEvents.shared.accessibilityTextSizeChanged.send(()) } } -// MARK: - Database Type Colors (preserved from old Theme.swift) - -extension DatabaseType { +internal extension DatabaseType { @MainActor var themeColor: Color { PluginManager.shared.brandColor(for: self) } } - -// MARK: - View Extensions (preserved from old Theme.swift) - -extension View { - func cardStyle() -> some View { - self - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } -} diff --git a/TablePro/Theme/ThemeFonts.swift b/TablePro/Theme/ThemeFonts.swift deleted file mode 100644 index 8fa0901dd9..0000000000 --- a/TablePro/Theme/ThemeFonts.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ThemeFonts.swift -// TablePro -// - -import Foundation - -// MARK: - Theme Fonts - -internal struct ThemeFonts: Codable, Equatable, Sendable { - var editorFontFamily: String - var editorFontSize: Int - var dataGridFontFamily: String - var dataGridFontSize: Int - - static let `default` = ThemeFonts( - editorFontFamily: "System Mono", - editorFontSize: 13, - dataGridFontFamily: "System Mono", - dataGridFontSize: 13 - ) - - init(editorFontFamily: String, editorFontSize: Int, dataGridFontFamily: String, dataGridFontSize: Int) { - self.editorFontFamily = editorFontFamily - self.editorFontSize = editorFontSize - self.dataGridFontFamily = dataGridFontFamily - self.dataGridFontSize = dataGridFontSize - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = ThemeFonts.default - - editorFontFamily = try container.decodeIfPresent(String.self, forKey: .editorFontFamily) - ?? fallback.editorFontFamily - editorFontSize = try container.decodeIfPresent(Int.self, forKey: .editorFontSize) ?? fallback.editorFontSize - dataGridFontFamily = try container.decodeIfPresent(String.self, forKey: .dataGridFontFamily) - ?? fallback.dataGridFontFamily - dataGridFontSize = try container.decodeIfPresent(Int.self, forKey: .dataGridFontSize) - ?? fallback.dataGridFontSize - } -} diff --git a/TablePro/Theme/ThemeLoadError.swift b/TablePro/Theme/ThemeLoadError.swift new file mode 100644 index 0000000000..9c20973d08 --- /dev/null +++ b/TablePro/Theme/ThemeLoadError.swift @@ -0,0 +1,55 @@ +import Foundation + +internal enum ThemeLoadError: LocalizedError, Equatable { + case missingSchema + case schemaTooOld(found: Int, supported: Int) + case schemaTooNew(found: Int, supported: Int) + case missingField(String) + case missingSlots([String]) + case invalidColor(String) + case unknownSystemColor(String) + case unknownKeys([String]) + case reservedIdentifier(String) + case invalidIdentifier(String) + + internal var errorDescription: String? { + switch self { + case .missingSchema: + return String(localized: "The file does not declare a theme format version.") + case let .schemaTooOld(found, supported): + return String( + format: String(localized: "Theme format %1$d is no longer supported. This version of TablePro reads format %2$d."), + found, + supported + ) + case let .schemaTooNew(found, supported): + return String( + format: String(localized: "Theme format %1$d was made for a newer TablePro. This version reads format %2$d."), + found, + supported + ) + case let .missingField(name): + return String(format: String(localized: "The theme is missing the required field \"%@\"."), name) + case let .missingSlots(names): + return String( + format: String(localized: "The theme is missing %1$d required colors, starting with \"%2$@\"."), + names.count, + names.first ?? "" + ) + case let .invalidColor(value): + return String(format: String(localized: "\"%@\" is not a valid color."), value) + case let .unknownSystemColor(name): + return String(format: String(localized: "\"%@\" is not a system color TablePro knows."), name) + case let .unknownKeys(names): + return String( + format: String(localized: "The theme declares %1$d colors TablePro does not use, starting with \"%2$@\"."), + names.count, + names.first ?? "" + ) + case let .reservedIdentifier(identifier): + return String(format: String(localized: "\"%@\" is a reserved theme identifier."), identifier) + case let .invalidIdentifier(identifier): + return String(format: String(localized: "\"%@\" is not a valid theme identifier."), identifier) + } + } +} diff --git a/TablePro/Theme/ThemePalette.swift b/TablePro/Theme/ThemePalette.swift new file mode 100644 index 0000000000..18d1a88c31 --- /dev/null +++ b/TablePro/Theme/ThemePalette.swift @@ -0,0 +1,134 @@ +import AppKit +import os +import SwiftUI + +internal struct ThemePair: Equatable, Sendable { + var light: ThemeDefinition + var dark: ThemeDefinition + + internal subscript(appearance: ThemeAppearance) -> ThemeDefinition { + switch appearance { + case .light: return light + case .dark: return dark + } + } + + internal static let builtIn = ThemePair(light: BuiltInThemes.light, dark: BuiltInThemes.dark) +} + +/// The live pair every dynamic slot colour reads through. A colour object an AppKit view is +/// already holding therefore answers for the theme in effect now, the way `NSColor.labelColor` +/// does, so marking that view for display is enough. Capturing the pair inside the provider +/// instead would freeze each colour at the revision that built it. +internal final class ThemeSource: Sendable { + internal static let shared = ThemeSource() + + private let state = OSAllocatedUnfairLock(initialState: ThemePair.builtIn) + + internal var pair: ThemePair { + state.withLock { $0 } + } + + internal func update(_ pair: ThemePair) { + state.withLock { $0 = pair } + } +} + +/// The dynamic tier. One `NSColor(name:dynamicProvider:)` per slot, so a slot resolves against +/// whatever appearance the view is drawing in, including the vibrant appearances inside a sidebar. +/// The revision only names the colour: SwiftUI caches `Color(nsColor:)` by object identity, so a +/// stable object would never repaint on a theme swap even though the provider answers correctly. +internal struct ThemePalette { + internal let revision: Int + + private let colors: [ThemeSlot: NSColor] + + internal init(revision: Int, source: ThemeSource = .shared) { + self.revision = revision + + var built: [ThemeSlot: NSColor] = [:] + built.reserveCapacity(ThemeSlot.allCases.count) + + for slot in ThemeSlot.allCases { + let name = NSColor.Name("tablepro.\(slot.rawValue).\(revision)") + built[slot] = NSColor(name: name) { appearance in + let definition = source.pair[ThemeAppearance(matching: appearance)] + return definition[keyPath: slot.keyPath].nsColor + } + } + + colors = built + } + + internal subscript(slot: ThemeSlot) -> NSColor { + colors[slot] ?? .labelColor + } + + internal func color(_ slot: ThemeSlot) -> Color { + Color(nsColor: self[slot]) + } +} + +/// The static tier. Snapshot consumers (the CodeEdit `EditorTheme`, `CALayer` colours, the data +/// grid's per-cell draw path) resolve once against the appearance the app has actually settled on. +/// They must not resolve a dynamic colour implicitly: outside a draw pass +/// `NSAppearance.currentDrawing()` still reports the old appearance for at least half a second +/// after `NSApp.appearance` changes, and a dynamic `cgColor` costs 254ns against 11ns for a +/// static one, which the grid pays per cell. +internal struct ResolvedTheme: Equatable { + internal let definition: ThemeDefinition + internal let appearance: ThemeAppearance + + private let colors: [ThemeSlot: NSColor] + + internal init(definition: ThemeDefinition, appearance: ThemeAppearance) { + self.definition = definition + self.appearance = appearance + + let nsAppearance = NSAppearance(named: appearance == .dark ? .darkAqua : .aqua) + var built: [ThemeSlot: NSColor] = [:] + built.reserveCapacity(ThemeSlot.allCases.count) + + let resolve = { + for slot in ThemeSlot.allCases { + let value = definition[keyPath: slot.keyPath] + built[slot] = value.nsColor.usingColorSpace(.sRGB) ?? value.nsColor + } + } + + if let nsAppearance { + nsAppearance.performAsCurrentDrawingAppearance(resolve) + } else { + resolve() + } + + colors = built + } + + internal subscript(slot: ThemeSlot) -> NSColor { + colors[slot] ?? .labelColor + } + + internal func color(_ slot: ThemeSlot) -> Color { + Color(nsColor: self[slot]) + } + + internal func cgColor(_ slot: ThemeSlot) -> CGColor { + self[slot].cgColor + } +} + +internal extension ThemeAppearance { + init(matching appearance: NSAppearance) { + let match = appearance.bestMatch(from: [.aqua, .darkAqua]) + self = match == .darkAqua ? .dark : .light + } +} + +/// Sent on a theme change and on an effective-appearance change, because the snapshot consumers +/// need both: a forced Light/Dark switch never changes the theme pair, and a theme switch never +/// changes the appearance. +internal struct ThemeChange: Equatable, Sendable { + internal let revision: Int + internal let appearance: ThemeAppearance +} diff --git a/TablePro/Theme/ThemeRegistryInstaller.swift b/TablePro/Theme/ThemeRegistryInstaller.swift index 4a7644a43f..fcdca6fdd0 100644 --- a/TablePro/Theme/ThemeRegistryInstaller.swift +++ b/TablePro/Theme/ThemeRegistryInstaller.swift @@ -31,10 +31,12 @@ internal final class ThemeRegistryInstaller { let decodedThemes = try await downloadAndDecode(plugin, progress: progress) + _ = try? removeRegistryFiles(for: plugin.id) + var installedThemes: [InstalledRegistryTheme] = [] for theme in decodedThemes { - try ThemeStorage.saveRegistryTheme(theme) + try ThemeCatalog.shared.saveRegistryTheme(theme) installedThemes.append(InstalledRegistryTheme( id: theme.id, @@ -44,11 +46,12 @@ internal final class ThemeRegistryInstaller { )) } - var meta = ThemeStorage.loadRegistryMeta() + var meta = ThemeCatalog.shared.loadRegistryMeta() meta.installed.append(contentsOf: installedThemes) - try ThemeStorage.saveRegistryMeta(meta) + try ThemeCatalog.shared.saveRegistryMeta(meta) - ThemeEngine.shared.reloadAvailableThemes() + ThemeCatalog.shared.reloadSynchronously() + reactivate() progress(1.0) Self.logger.info("Installed \(installedThemes.count) theme(s) from registry plugin: \(plugin.id)") @@ -57,26 +60,10 @@ internal final class ThemeRegistryInstaller { // MARK: - Uninstall func uninstall(registryPluginId: String) throws { - let removedThemeIds = try removeRegistryFiles(for: registryPluginId) - - ThemeEngine.shared.reloadAvailableThemes() + _ = try removeRegistryFiles(for: registryPluginId) - // Reset preferred theme slots if the uninstalled theme was preferred - var appearance = AppSettingsManager.shared.appearance - var changed = false - for id in removedThemeIds { - if id == appearance.preferredLightThemeId { - appearance.preferredLightThemeId = "tablepro.default-light" - changed = true - } - if id == appearance.preferredDarkThemeId { - appearance.preferredDarkThemeId = "tablepro.default-dark" - changed = true - } - } - if changed { - AppSettingsManager.shared.appearance = appearance - } + ThemeCatalog.shared.reloadSynchronously() + reactivate() Self.logger.info("Uninstalled registry themes for plugin: \(registryPluginId)") } @@ -87,9 +74,6 @@ internal final class ThemeRegistryInstaller { _ plugin: RegistryPlugin, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws { - let activeId = ThemeEngine.shared.activeTheme.id - - // Download, verify, and decode new themes first (no side effects yet) let stagedThemes = try await downloadAndDecode(plugin, progress: progress) // Remove old files without triggering theme reload or fallback @@ -97,7 +81,7 @@ internal final class ThemeRegistryInstaller { var installedThemes: [InstalledRegistryTheme] = [] for theme in stagedThemes { - try ThemeStorage.saveRegistryTheme(theme) + try ThemeCatalog.shared.saveRegistryTheme(theme) installedThemes.append(InstalledRegistryTheme( id: theme.id, registryPluginId: plugin.id, @@ -106,20 +90,12 @@ internal final class ThemeRegistryInstaller { )) } - var meta = ThemeStorage.loadRegistryMeta() + var meta = ThemeCatalog.shared.loadRegistryMeta() meta.installed.append(contentsOf: installedThemes) - try ThemeStorage.saveRegistryMeta(meta) + try ThemeCatalog.shared.saveRegistryMeta(meta) - // Single reload after swap is complete — no intermediate flicker - ThemeEngine.shared.reloadAvailableThemes() - - // Re-activate the correct theme for the current appearance - let appearance = AppSettingsManager.shared.appearance - ThemeEngine.shared.updateAppearanceAndTheme( - mode: appearance.appearanceMode, - lightThemeId: appearance.preferredLightThemeId, - darkThemeId: appearance.preferredDarkThemeId - ) + ThemeCatalog.shared.reloadSynchronously() + reactivate() Self.logger.info("Updated \(installedThemes.count) theme(s) for registry plugin: \(plugin.id)") } @@ -128,16 +104,16 @@ internal final class ThemeRegistryInstaller { /// Does NOT reload ThemeEngine or trigger fallback — callers manage that. @discardableResult private func removeRegistryFiles(for registryPluginId: String) throws -> Set { - var meta = ThemeStorage.loadRegistryMeta() + var meta = ThemeCatalog.shared.loadRegistryMeta() let themesToRemove = meta.installed.filter { $0.registryPluginId == registryPluginId } let removedIds = Set(themesToRemove.map(\.id)) meta.installed.removeAll { $0.registryPluginId == registryPluginId } - try ThemeStorage.saveRegistryMeta(meta) + try ThemeCatalog.shared.saveRegistryMeta(meta) for entry in themesToRemove { do { - try ThemeStorage.deleteRegistryTheme(id: entry.id) + try ThemeCatalog.shared.deleteRegistryTheme(id: entry.id) } catch { Self.logger.warning("Failed to delete registry theme file \(entry.id): \(error)") } @@ -149,17 +125,20 @@ internal final class ThemeRegistryInstaller { // MARK: - Query func isInstalled(_ registryPluginId: String) -> Bool { - let meta = ThemeStorage.loadRegistryMeta() - return meta.installed.contains { $0.registryPluginId == registryPluginId } + let entries = ThemeCatalog.shared.loadRegistryMeta().installed + .filter { $0.registryPluginId == registryPluginId } + + guard !entries.isEmpty else { return false } + return entries.allSatisfy { ThemeCatalog.shared.theme(id: $0.id) != nil } } func installedVersion(for registryPluginId: String) -> String? { - let meta = ThemeStorage.loadRegistryMeta() + let meta = ThemeCatalog.shared.loadRegistryMeta() return meta.installed.first { $0.registryPluginId == registryPluginId }?.version } func availableUpdates(manifest: RegistryManifest) -> [RegistryPlugin] { - let meta = ThemeStorage.loadRegistryMeta() + let meta = ThemeCatalog.shared.loadRegistryMeta() let installedVersions = Dictionary( meta.installed.map { ($0.registryPluginId, $0.version) }, uniquingKeysWith: { first, _ in first } @@ -250,14 +229,12 @@ internal final class ThemeRegistryInstaller { progress(0.9) - let decoder = JSONDecoder() var decodedThemes: [ThemeDefinition] = [] for jsonURL in jsonFiles { - let data = try Data(contentsOf: jsonURL) - var theme = try decoder.decode(ThemeDefinition.self, from: data) - let originalId = theme.id - theme.id = "registry.\(plugin.id).\(originalId)" + let document = try ThemeDocument(data: try Data(contentsOf: jsonURL)) + var theme = document.resolved() + theme.id = "\(ThemeDefinition.registryPrefix)\(plugin.id).\(theme.id)" decodedThemes.append(theme) } @@ -271,6 +248,14 @@ internal final class ThemeRegistryInstaller { // MARK: - Helpers + private func reactivate() { + let appearance = AppSettingsManager.shared.appearance + ThemeEngine.shared.reapply( + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) + } + private func findJsonFiles(in directory: URL) throws -> [URL] { var results: [URL] = [] let fm = FileManager.default diff --git a/TablePro/Theme/ThemeResolver.swift b/TablePro/Theme/ThemeResolver.swift new file mode 100644 index 0000000000..084548c3ad --- /dev/null +++ b/TablePro/Theme/ThemeResolver.swift @@ -0,0 +1,68 @@ +import Foundation + +internal struct ThemeSelection: Equatable, Sendable { + internal let pair: ThemePair + internal let effectiveAppearance: ThemeAppearance + + internal var active: ThemeDefinition { pair[effectiveAppearance] } +} + +/// Pure. It never writes settings: a settings pane that rewrote the saved theme id to make its own +/// filter true silently replaced a theme the user had chosen and then hid it from the list they +/// would have used to put it back. A slot whose theme is missing, rejected, or of the wrong +/// appearance falls back to that slot's own built-in, so a broken dark theme never paints Default +/// Light inside dark chrome. +internal enum ThemeResolver { + internal static func resolve( + mode: AppAppearanceMode, + lightThemeId: String, + darkThemeId: String, + themes: [ThemeDefinition], + systemIsDark: Bool + ) -> ThemeSelection { + let pair = ThemePair( + light: theme(id: lightThemeId, slot: .light, in: themes), + dark: theme(id: darkThemeId, slot: .dark, in: themes) + ) + + return ThemeSelection( + pair: pair, + effectiveAppearance: effectiveAppearance(mode: mode, systemIsDark: systemIsDark) + ) + } + + internal static func effectiveAppearance(mode: AppAppearanceMode, systemIsDark: Bool) -> ThemeAppearance { + switch mode { + case .light: return .light + case .dark: return .dark + case .auto: return systemIsDark ? .dark : .light + } + } + + private static func theme( + id: String, + slot: ThemeAppearance, + in themes: [ThemeDefinition] + ) -> ThemeDefinition { + guard let found = themes.first(where: { $0.id == id }), found.appearance == slot else { + return BuiltInThemes.default(for: slot) + } + return found + } +} + +/// The list a slot offers. A theme only fits the slot whose appearance it declares, and the theme +/// the slot already holds stays listed so the user can always see and re-pick their own choice. +internal enum ThemeSlotValidation { + internal static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { + appearance == slot + } + + internal static func eligibleThemes( + _ themes: [ThemeDefinition], + slot: ThemeAppearance, + keeping selectedId: String? + ) -> [ThemeDefinition] { + themes.filter { fits($0.appearance, slot: slot) || $0.id == selectedId } + } +} diff --git a/TablePro/Theme/ThemeSlotValidation.swift b/TablePro/Theme/ThemeSlotValidation.swift deleted file mode 100644 index 61343bd965..0000000000 --- a/TablePro/Theme/ThemeSlotValidation.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ThemeSlotValidation.swift -// TablePro -// - -import Foundation - -/// `ThemeDefinition.appearance` was declared but never read, so a dark theme could be assigned to -/// the light slot and the app would honour it. The list is filtered to the themes that suit the -/// slot, and the theme the slot already holds is always kept in it. -/// -/// Rewriting the saved id to make the filter true is not an option: a settings pane is a -/// presentation surface, and the rewrite fired on appear, so opening Appearance silently replaced -/// a theme the user had chosen and then hid it from the list they would have used to put it back. -internal enum ThemeSlotValidation { - internal static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { - appearance == .auto || appearance == slot - } - - internal static func eligibleThemes( - _ themes: [ThemeDefinition], - slot: ThemeAppearance, - keeping selectedId: String? - ) -> [ThemeDefinition] { - themes.filter { fits($0.appearance, slot: slot) || $0.id == selectedId } - } -} diff --git a/TablePro/Theme/ThemeStorage.swift b/TablePro/Theme/ThemeStorage.swift deleted file mode 100644 index c0cbf9f154..0000000000 --- a/TablePro/Theme/ThemeStorage.swift +++ /dev/null @@ -1,258 +0,0 @@ -// -// ThemeStorage.swift -// TablePro -// -// File I/O for theme JSON files. -// Built-in themes loaded from app bundle, user themes from Application Support. -// - -import Foundation -import os - -internal struct ThemeStorage { - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeStorage") - - private static let userThemesDirectory: URL = - AppStorageEnvironment.shared.applicationSupportRoot.appendingPathComponent("TablePro/Themes", isDirectory: true) - - private static let bundledThemesDirectory: URL? = { - Bundle.main.resourceURL - }() - - private static let registryThemesDirectory: URL = { - userThemesDirectory.appendingPathComponent("Registry", isDirectory: true) - }() - - private static func themeFileURL(in directory: URL, id: String) throws -> URL { - let allowed = #"^[A-Za-z0-9._-]+$"# - guard id.range(of: allowed, options: .regularExpression) != nil else { - throw CocoaError(.fileWriteInvalidFileName) - } - return directory.appendingPathComponent("\(id).json", isDirectory: false) - } - - // MARK: - Load All Themes - - static func loadAllThemes() -> [ThemeDefinition] { - var themes: [ThemeDefinition] = [] - - // Load built-in themes from app bundle (files copied flat to Resources/) - if let bundleDir = bundledThemesDirectory { - themes.append(contentsOf: loadBuiltInThemes(from: bundleDir)) - } - - if themes.isEmpty { - themes = [ThemeDefinition.default] - } - - ensureRegistryDirectory() - themes.append(contentsOf: loadThemes(from: registryThemesDirectory, isBuiltIn: false)) - - ensureUserDirectory() - themes.append(contentsOf: loadThemes(from: userThemesDirectory, isBuiltIn: false)) - - return themes - } - - // MARK: - Load Single Theme - - static func loadTheme(id: String) -> ThemeDefinition? { - let fm = FileManager.default - - if let userFile = try? themeFileURL(in: userThemesDirectory, id: id), - fm.fileExists(atPath: userFile.path), - let theme = loadTheme(from: userFile) { - return theme - } - - if let registryFile = try? themeFileURL(in: registryThemesDirectory, id: id), - fm.fileExists(atPath: registryFile.path), - let theme = loadTheme(from: registryFile) { - return theme - } - - // User themes are never bundled; skip the bundle search for them. - if !id.hasPrefix("user."), - let bundleDir = bundledThemesDirectory, - let bundleFile = try? themeFileURL(in: bundleDir, id: id), - fm.fileExists(atPath: bundleFile.path), - let theme = loadTheme(from: bundleFile) { - return theme - } - - return id == ThemeDefinition.default.id ? .default : nil - } - - // MARK: - Save User Theme - - static func saveUserTheme(_ theme: ThemeDefinition) throws { - ensureUserDirectory() - let url = try themeFileURL(in: userThemesDirectory, id: theme.id) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(theme) - try data.write(to: url, options: .atomic) - logger.info("Saved user theme: \(theme.id)") - } - - // MARK: - Delete User Theme - - static func deleteUserTheme(id: String) throws { - let url = try themeFileURL(in: userThemesDirectory, id: id) - guard FileManager.default.fileExists(atPath: url.path) else { return } - try FileManager.default.removeItem(at: url) - logger.info("Deleted user theme: \(id)") - } - - // MARK: - Save Registry Theme - - static func saveRegistryTheme(_ theme: ThemeDefinition) throws { - ensureRegistryDirectory() - let url = try themeFileURL(in: registryThemesDirectory, id: theme.id) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(theme) - try data.write(to: url, options: .atomic) - logger.info("Saved registry theme: \(theme.id)") - } - - // MARK: - Delete Registry Theme - - static func deleteRegistryTheme(id: String) throws { - let url = try themeFileURL(in: registryThemesDirectory, id: id) - guard FileManager.default.fileExists(atPath: url.path) else { return } - try FileManager.default.removeItem(at: url) - logger.info("Deleted registry theme: \(id)") - } - - // MARK: - Registry Meta - - private static let registryMetaURL: URL = { - registryThemesDirectory.appendingPathComponent("registry-meta.json") - }() - - static func loadRegistryMeta() -> RegistryThemeMeta { - guard FileManager.default.fileExists(atPath: registryMetaURL.path) else { - return RegistryThemeMeta() - } - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let data = try Data(contentsOf: registryMetaURL) - return try decoder.decode(RegistryThemeMeta.self, from: data) - } catch { - logger.error("Failed to load registry meta: \(error)") - return RegistryThemeMeta() - } - } - - static func saveRegistryMeta(_ meta: RegistryThemeMeta) throws { - ensureRegistryDirectory() - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(meta) - try data.write(to: registryMetaURL, options: .atomic) - } - - // MARK: - Import / Export - - static func importTheme(from sourceURL: URL) throws -> ThemeDefinition { - let data = try Data(contentsOf: sourceURL) - var theme = try JSONDecoder().decode(ThemeDefinition.self, from: data) - - // Avoid clobbering an existing theme on import - if theme.isBuiltIn || theme.isRegistry || loadTheme(id: theme.id) != nil { - theme.id = "user.\(UUID().uuidString.lowercased().prefix(8))" - } - - try saveUserTheme(theme) - return theme - } - - static func exportTheme(_ theme: ThemeDefinition, to destinationURL: URL) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(theme) - try data.write(to: destinationURL, options: .atomic) - logger.info("Exported theme: \(theme.id) to \(destinationURL.lastPathComponent)") - } - - // MARK: - Helpers - - private static func ensureUserDirectory() { - let fm = FileManager.default - if !fm.fileExists(atPath: userThemesDirectory.path) { - do { - try fm.createDirectory(at: userThemesDirectory, withIntermediateDirectories: true) - } catch { - logger.error("Failed to create user themes directory: \(error)") - } - } - } - - private static func ensureRegistryDirectory() { - let fm = FileManager.default - if !fm.fileExists(atPath: registryThemesDirectory.path) { - do { - try fm.createDirectory(at: registryThemesDirectory, withIntermediateDirectories: true) - } catch { - logger.error("Failed to create registry themes directory: \(error)") - } - } - } - - private static let builtInThemeOrder = [ - "tablepro.default-light", - "tablepro.default-dark", - "tablepro.dracula", - "tablepro.nord", - ] - - private static func loadBuiltInThemes(from directory: URL) -> [ThemeDefinition] { - let fm = FileManager.default - guard fm.fileExists(atPath: directory.path) else { return [] } - - do { - let files = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" && $0.lastPathComponent.hasPrefix("tablepro.") } - - let themes = files.compactMap { loadTheme(from: $0) } - return themes.sorted { lhs, rhs in - let li = builtInThemeOrder.firstIndex(of: lhs.id) ?? Int.max - let ri = builtInThemeOrder.firstIndex(of: rhs.id) ?? Int.max - return li < ri - } - } catch { - logger.error("Failed to list built-in themes: \(error)") - return [] - } - } - - private static func loadThemes(from directory: URL, isBuiltIn: Bool) -> [ThemeDefinition] { - let fm = FileManager.default - guard fm.fileExists(atPath: directory.path) else { return [] } - - do { - let files = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" && $0.lastPathComponent != "registry-meta.json" } - - return files.compactMap { loadTheme(from: $0) } - } catch { - logger.error("Failed to list themes in \(directory.lastPathComponent): \(error)") - return [] - } - } - - private static func loadTheme(from url: URL) -> ThemeDefinition? { - do { - let data = try Data(contentsOf: url) - return try JSONDecoder().decode(ThemeDefinition.self, from: data) - } catch CocoaError.fileNoSuchFile, CocoaError.fileReadNoSuchFile { - return nil - } catch { - logger.error("Failed to load theme from \(url.lastPathComponent): \(error)") - return nil - } - } -} diff --git a/TablePro/Theme/ThemedContentSurface.swift b/TablePro/Theme/ThemedContentSurface.swift new file mode 100644 index 0000000000..16998c8479 --- /dev/null +++ b/TablePro/Theme/ThemedContentSurface.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Re-resolves `.secondary` and `.tertiary` inside a content pane to the theme's own text levels, +/// which is how 184 hierarchical foreground styles across the content surfaces follow a theme +/// without each one being rewritten. +/// +/// It never branches on the theme. `WorkspacePanes` depends on every pane builder erasing one +/// stable view identity, so an `if` here would tear down the grid, the editor's undo stack and the +/// scroll position on a theme change. It reads the engine inside `body`, so the palette it applies +/// is the current one rather than whatever was captured when the pane was built. +private struct ThemedContentSurface: ViewModifier { + func body(content: Content) -> some View { + let palette = ThemeEngine.shared.palette + + return content.foregroundStyle( + palette.color(.panelText), + palette.color(.panelSecondaryText), + palette.color(.panelTertiaryText) + ) + } +} + +internal extension View { + func themedContent() -> some View { + modifier(ThemedContentSurface()) + } +} diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift index 239e408cfc..18308c80d3 100644 --- a/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -52,6 +52,8 @@ final class ServerDashboardViewModel { @ObservationIgnored nonisolated(unsafe) private var refreshTask: Task? @ObservationIgnored private let services: AppServices + @ObservationIgnored private var providerServerVersion: String? + @ObservationIgnored private var hasAdoptedServerVersion = false // MARK: - Computed Properties @@ -115,14 +117,28 @@ final class ServerDashboardViewModel { // MARK: - Data Fetching + /// The provider is built once per server version, because it also answers `supportedPanels`, + /// `canKillSessions` and `canCancelQueries` for the toolbar between refreshes. + private func adoptProvider(forServerVersion serverVersion: String?) { + guard serverVersion != providerServerVersion || !hasAdoptedServerVersion else { return } + providerServerVersion = serverVersion + hasAdoptedServerVersion = true + guard let resolved = ServerDashboardQueryProviderFactory.provider( + for: databaseType, serverVersion: serverVersion + ) else { return } + provider = resolved + } + func refreshNow() async { guard !isRefreshing else { return } - guard let provider else { + guard provider != nil else { Self.logger.warning("No query provider available for \(self.databaseType.rawValue)") return } - guard services.databaseManager.driver(for: connectionId) != nil else { return } + guard let liveDriver = services.databaseManager.driver(for: connectionId) else { return } + adoptProvider(forServerVersion: liveDriver.serverVersion) + guard let provider else { return } isRefreshing = true defer { isRefreshing = false } diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift index de981e47ef..6f91e71141 100644 --- a/TablePro/Views/Backup/BackupResultSheet.swift +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -15,7 +15,7 @@ struct BackupResultSheet: View { enum Outcome { case backupSuccess(database: String, destination: URL, bytes: Int64) - case restoreSuccess(database: String, source: URL) + case restoreSuccess(database: String, source: URL, skippedSettings: [String]) /// A run over several databases, where one failing does not stop the rest, so the sheet /// reports every database rather than one verdict for the batch. case batch(outcomes: [NativeDumpBatchOutcome], directory: URL) @@ -84,16 +84,32 @@ struct BackupResultSheet: View { scrollingDetail(message) case .batch(let outcomes, let directory): scrollingDetail(Self.batchDetail(outcomes, directory: directory)) - default: - if let detail { - Text(detail) + case .restoreSuccess(_, _, let skippedSettings): + summaryDetail + if let note = Self.skippedSettingsNote(skippedSettings) { + Text(note) .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .lineLimit(6) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .center) .textSelection(.enabled) } + default: + summaryDetail + } + } + + @ViewBuilder + private var summaryDetail: some View { + if let detail { + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(6) + .frame(maxWidth: .infinity, alignment: .center) + .textSelection(.enabled) } } @@ -179,7 +195,7 @@ struct BackupResultSheet: View { database, destination.path ) - case .restoreSuccess(let database, let source): + case .restoreSuccess(let database, let source, _): return String( format: String(localized: "Restored \u{201C}%@\u{201D} from %@"), database, @@ -198,6 +214,20 @@ struct BackupResultSheet: View { } } + internal static func skippedSettingsNote(_ settings: [String]) -> String? { + guard let first = settings.first else { return nil } + guard settings.count > 1 else { + return String( + format: String(localized: "Skipped the %@ setting, which this server does not recognize."), + first + ) + } + return String( + format: String(localized: "Skipped settings this server does not recognize: %@."), + settings.formatted(.list(type: .and)) + ) + } + /// One line per database, so a run where the second of three failed says which one and keeps /// the other two visible rather than reporting a single verdict for the batch. private static func batchDetail(_ outcomes: [NativeDumpBatchOutcome], directory: URL) -> String { @@ -242,7 +272,21 @@ struct BackupResultSheet: View { kind: .restore, outcome: .restoreSuccess( database: "production", - source: URL(fileURLWithPath: "/Users/me/Desktop/production.dump") + source: URL(fileURLWithPath: "/Users/me/Desktop/production.dump"), + skippedSettings: [] + ), + onClose: {}, + onShowInFinder: nil + ) +} + +#Preview("Restore Success With Skipped Settings") { + BackupResultSheet( + kind: .restore, + outcome: .restoreSuccess( + database: "production", + source: URL(fileURLWithPath: "/Users/me/Desktop/production.dump"), + skippedSettings: ["idle_in_transaction_session_timeout", "transaction_timeout"] ), onClose: {}, onShowInFinder: nil diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index 19b5c2c2b2..f3266cfbc5 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -20,7 +20,7 @@ struct RestoreDatabaseFlow: View { case resolvingTarget case pickDatabase case running(database: String) - case finished(database: String) + case finished(database: String, skippedSettings: [String]) case failed(message: String, targetMayBeModified: Bool) case cancelled } @@ -41,10 +41,11 @@ struct RestoreDatabaseFlow: View { isCancelling: service.state == .cancelling, onCancel: { service.cancel() } ) - case .finished(let database): + case .finished(let database, let skippedSettings): BackupResultSheet( kind: .restore, - outcome: .restoreSuccess(database: database, source: sourceURL), + outcome: .restoreSuccess( + database: database, source: sourceURL, skippedSettings: skippedSettings), onClose: { isPresented = false }, onShowInFinder: nil ) @@ -152,8 +153,8 @@ struct RestoreDatabaseFlow: View { switch state { case .running(let database, _, _, _): phase = .running(database: database) - case .finished(let database, _, _): - phase = .finished(database: database) + case .finished(let database, _, _, let skippedSettings): + phase = .finished(database: database, skippedSettings: skippedSettings) case .failed(let message, let targetMayBeModified): phase = .failed(message: message, targetMayBeModified: targetMayBeModified) case .cancelled: diff --git a/TablePro/Views/Compare/CompareStatusStyle.swift b/TablePro/Views/Compare/CompareStatusStyle.swift index 754ddcb06b..75d8f13350 100644 --- a/TablePro/Views/Compare/CompareStatusStyle.swift +++ b/TablePro/Views/Compare/CompareStatusStyle.swift @@ -45,16 +45,16 @@ internal enum CompareStatusStyle { @MainActor internal static func tint(for status: TableDiffStatus) -> Color { - let colors = ThemeEngine.shared.colors.ui + let palette = ThemeEngine.shared.palette switch status { case .onlyInSource: - return colors.successSwiftUI + return palette.color(.statusSuccess) case .onlyInTarget: - return colors.errorSwiftUI + return palette.color(.statusError) case .differs: - return colors.warningSwiftUI + return palette.color(.statusWarning) case .identical: - return colors.secondaryTextSwiftUI + return Color(nsColor: .secondaryLabelColor) } } @@ -88,16 +88,16 @@ internal enum CompareStatusStyle { @MainActor internal static func tint(for kind: RowDiffKind) -> Color { - let colors = ThemeEngine.shared.colors.ui + let palette = ThemeEngine.shared.palette switch kind { case .insert: - return colors.successSwiftUI + return palette.color(.statusSuccess) case .update: - return colors.warningSwiftUI + return palette.color(.statusWarning) case .delete: - return colors.errorSwiftUI + return palette.color(.statusError) case .identical: - return colors.secondaryTextSwiftUI + return Color(nsColor: .secondaryLabelColor) } } @@ -105,14 +105,14 @@ internal enum CompareStatusStyle { /// difference here reads the way the same row reads in the grid. @MainActor internal static func rowTint(for kind: RowDiffKind) -> Color { - let colors = ThemeEngine.shared.colors.dataGrid + let palette = ThemeEngine.shared.palette switch kind { case .insert: - return colors.insertedSwiftUI + return palette.color(.gridInserted) case .update: - return colors.modifiedSwiftUI + return palette.color(.gridModified) case .delete: - return colors.deletedSwiftUI + return palette.color(.gridDeleted) case .identical: return .clear } @@ -122,16 +122,16 @@ internal enum CompareStatusStyle { @MainActor internal static var warning: Color { - ThemeEngine.shared.colors.ui.warningSwiftUI + ThemeEngine.shared.palette.color(.statusWarning) } @MainActor internal static var error: Color { - ThemeEngine.shared.colors.ui.errorSwiftUI + ThemeEngine.shared.palette.color(.statusError) } @MainActor internal static var success: Color { - ThemeEngine.shared.colors.ui.successSwiftUI + ThemeEngine.shared.palette.color(.statusSuccess) } } diff --git a/TablePro/Views/Components/ClosureMenuTarget.swift b/TablePro/Views/Components/ClosureMenuTarget.swift new file mode 100644 index 0000000000..0e85c2f969 --- /dev/null +++ b/TablePro/Views/Components/ClosureMenuTarget.swift @@ -0,0 +1,31 @@ +// +// ClosureMenuTarget.swift +// TablePro +// + +import AppKit + +/// `NSMenuItem` holds its target weakly, so the closure needs an owner that outlives the menu. +/// `representedObject` is that owner: it is strong, it belongs to the item, and it goes when the +/// item does. +@MainActor +final class ClosureMenuTarget: NSObject { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func fire() { + action() + } + + static func item(title: String, isEnabled: Bool = true, action: @escaping () -> Void) -> NSMenuItem { + let item = NSMenuItem(title: title, action: #selector(fire), keyEquivalent: "") + let target = ClosureMenuTarget(action: action) + item.target = target + item.representedObject = target + item.isEnabled = isEnabled + return item + } +} diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index db27ed5e38..b7d34e7e21 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -13,7 +13,6 @@ import TableProPluginKit /// Read-only text view that applies SQL/MQL syntax highlighting via regex struct HighlightedSQLTextView: NSViewRepresentable { let sql: String - var fontSize: CGFloat = 13 var databaseType: DatabaseType = .mysql /// A SwiftUI `.accessibilityIdentifier` lands on the representable's wrapper, not on the text /// view AppKit publishes, so the only way to name this element is to set it on the text view. @@ -30,10 +29,10 @@ struct HighlightedSQLTextView: NSViewRepresentable { textView.setAccessibilityLabel(String(localized: "Query preview")) textView.isEditable = false textView.isSelectable = true - textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + textView.font = ThemeEngine.shared.editorFonts.font textView.textContainerInset = NSSize(width: 12, height: 12) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.labelColor + textView.backgroundColor = ThemeEngine.shared.palette[.editorBackground] + textView.textColor = ThemeEngine.shared.palette[.editorText] // Disable line wrapping textView.textContainer?.widthTracksTextView = false @@ -49,27 +48,47 @@ struct HighlightedSQLTextView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? NSTextView else { return } - if let currentFont = textView.font, currentFont.pointSize != fontSize { - textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) - if !textView.string.isEmpty { - applyHighlighting(to: textView) - } + let editorFont = ThemeEngine.shared.editorFonts.font + let fontChanged = textView.font != editorFont + let textChanged = textView.string != sql + + if fontChanged { + textView.font = editorFont } + textView.backgroundColor = ThemeEngine.shared.palette[.editorBackground] + textView.textColor = ThemeEngine.shared.palette[.editorText] - if textView.string != sql { + if textChanged { textView.string = sql - if !sql.isEmpty { - applyHighlighting(to: textView) - } } + + /// The highlighting bakes a colour per range, so it is applied again whenever the theme or + /// the editor font moves, not only when the query does. + guard !sql.isEmpty, textChanged || fontChanged || context.coordinator.revision != themeRevision else { + return + } + context.coordinator.revision = themeRevision + applyHighlighting(to: textView) + } + + private var themeRevision: Int { + ThemeEngine.shared.revision + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator { + var revision = -1 } // MARK: - Syntax Highlighting // MARK: - Pre-compiled Syntax Patterns - private static let syntaxPatterns: [(regex: NSRegularExpression, color: NSColor)] = { - var patterns: [(NSRegularExpression, NSColor)] = [] + private static let syntaxPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { + var patterns: [(NSRegularExpression, ThemeSlot)] = [] // SQL Keywords (blue) — single alternation regex for all keywords let keywords = [ @@ -86,22 +105,22 @@ struct HighlightedSQLTextView: NSViewRepresentable { ] let keywordPattern = "\\b(" + keywords.joined(separator: "|") + ")\\b" if let regex = try? NSRegularExpression(pattern: keywordPattern, options: .caseInsensitive) { - patterns.append((regex, .systemBlue)) + patterns.append((regex, .syntaxKeyword)) } // Strings (red) if let regex = try? NSRegularExpression(pattern: "'[^']*'", options: .caseInsensitive) { - patterns.append((regex, .systemRed)) + patterns.append((regex, .syntaxString)) } // Backticks (orange) if let regex = try? NSRegularExpression(pattern: "`[^`]*`", options: .caseInsensitive) { - patterns.append((regex, .systemOrange)) + patterns.append((regex, .syntaxNull)) } // Numbers (purple) if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\b", options: .caseInsensitive) { - patterns.append((regex, .systemPurple)) + patterns.append((regex, .syntaxNumber)) } return patterns @@ -109,8 +128,8 @@ struct HighlightedSQLTextView: NSViewRepresentable { // MARK: - Pre-compiled MQL Syntax Patterns - private static let mqlPatterns: [(regex: NSRegularExpression, color: NSColor)] = { - var patterns: [(NSRegularExpression, NSColor)] = [] + private static let mqlPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { + var patterns: [(NSRegularExpression, ThemeSlot)] = [] // MongoDB methods (blue) — single alternation regex for all methods let methods = [ @@ -121,12 +140,12 @@ struct HighlightedSQLTextView: NSViewRepresentable { ] let methodPattern = "\\.(" + methods.joined(separator: "|") + ")\\s*\\(" if let regex = try? NSRegularExpression(pattern: methodPattern, options: []) { - patterns.append((regex, .systemBlue)) + patterns.append((regex, .syntaxKeyword)) } // db. prefix (blue) if let regex = try? NSRegularExpression(pattern: "\\bdb\\.", options: []) { - patterns.append((regex, .systemBlue)) + patterns.append((regex, .syntaxKeyword)) } // MongoDB operators $gt, $lt, $in, etc. (teal) @@ -137,22 +156,22 @@ struct HighlightedSQLTextView: NSViewRepresentable { + "oid|numberInt|numberLong|numberDouble|date|binary|timestamp|numberDecimal)\"", options: [] ) { - patterns.append((regex, .systemTeal)) + patterns.append((regex, .syntaxType)) } // Strings (red) if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"", options: []) { - patterns.append((regex, .systemRed)) + patterns.append((regex, .syntaxString)) } // Numbers (purple) if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\.?\\d*\\b", options: []) { - patterns.append((regex, .systemPurple)) + patterns.append((regex, .syntaxNumber)) } // Booleans and null (orange) if let regex = try? NSRegularExpression(pattern: "\\b(true|false|null)\\b", options: []) { - patterns.append((regex, .systemOrange)) + patterns.append((regex, .syntaxNull)) } return patterns @@ -167,12 +186,12 @@ struct HighlightedSQLTextView: NSViewRepresentable { textStorage.beginEditing() // Reset to base style - let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + let font = ThemeEngine.shared.editorFonts.font textStorage.addAttribute(.font, value: font, range: fullRange) - textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) + textStorage.addAttribute(.foregroundColor, value: ThemeEngine.shared.palette[.editorText], range: fullRange) // Apply pre-compiled patterns - let activePatterns: [(regex: NSRegularExpression, color: NSColor)] + let activePatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] switch PluginManager.shared.editorLanguage(for: databaseType) { case .javascript: activePatterns = Self.mqlPatterns @@ -187,10 +206,12 @@ struct HighlightedSQLTextView: NSViewRepresentable { } else { highlightRange = fullRange } - for (regex, color) in activePatterns { + let palette = ThemeEngine.shared.palette + + for (regex, slot) in activePatterns { let matches = regex.matches(in: text, options: [], range: highlightRange) for match in matches { - textStorage.addAttribute(.foregroundColor, value: color, range: match.range) + textStorage.addAttribute(.foregroundColor, value: palette[slot], range: match.range) } } diff --git a/TablePro/Views/Components/SQLReviewSheet.swift b/TablePro/Views/Components/SQLReviewSheet.swift index 9796e0d8b8..36ba33dd61 100644 --- a/TablePro/Views/Components/SQLReviewSheet.swift +++ b/TablePro/Views/Components/SQLReviewSheet.swift @@ -11,23 +11,68 @@ import TableProPluginKit struct SQLReviewSheet: View { struct PrimaryAction { + /// Answering a confirmation is instant and must not wait on a task being scheduled. The + /// windowless path holds the main actor inside `NSApp.runModal` until this button resolves + /// its gate, and the loop only unwinds once it does, so work deferred to a `Task` would be + /// waiting on the loop that is waiting on it. Applying a plan is the other shape: it takes + /// as long as the server does and wants the progress the task form gives it. + enum Work { + case immediate(@MainActor () -> Void) + case asynchronous(@MainActor () async -> Void) + } + let title: String let isDestructive: Bool - let perform: () async -> Void + /// Return belongs to the confirming button only when the user asked for this dialog. A + /// confirmation something else raised steals focus to do it, so a Return already on its way + /// to the user's editor would answer it. `AlertHelper.addConfirmAndCancel` takes Return off + /// the confirming button for the same reason. + let takesDefaultAction: Bool + let work: Work + + init( + title: String, + isDestructive: Bool, + takesDefaultAction: Bool = true, + perform: @escaping @MainActor () async -> Void + ) { + self.init( + title: title, + isDestructive: isDestructive, + takesDefaultAction: takesDefaultAction, + work: .asynchronous(perform) + ) + } - init(title: String, isDestructive: Bool, perform: @escaping () async -> Void) { + init( + title: String, + isDestructive: Bool, + takesDefaultAction: Bool = true, + work: Work + ) { self.title = title self.isDestructive = isDestructive - self.perform = perform + self.takesDefaultAction = takesDefaultAction + self.work = work } } + /// The one way out. `@Environment(\.dismiss)` cannot serve alongside it, because it is inert + /// once the sheet is hosted in an `NSWindow` rather than presented by SwiftUI, which is how a + /// statement confirmation reaches a Mac with no window open. @Binding var isPresented: Bool - @Environment(\.dismiss) private var dismiss let statements: [String] let databaseType: DatabaseType + /// Replaces the default " Preview" heading. A confirmation names the operation. + var title: String? + /// The sentence under the heading: who is asking, and which connection. + var subtitle: String? + /// Show the statements exactly as they will be sent. A preview may make MQL easier to read by + /// rewriting `{"$oid": "…"}` as `ObjectId("…")` and by ending each statement with a semicolon; + /// a confirmation may not, because the user is agreeing to the text in front of them. + var showsStatementsVerbatim = false var warning: String? var failure: String? var primaryAction: PrimaryAction? @@ -73,7 +118,7 @@ struct SQLReviewSheet: View { } .frame(width: 560, height: 460) .background(Color(nsColor: .windowBackgroundColor)) - .onExitCommand { dismiss() } + .onExitCommand { isPresented = false } .task { await prepare() } } @@ -93,28 +138,41 @@ struct SQLReviewSheet: View { private func prepare() async { guard prepared == nil, !statements.isEmpty else { return } - let isJavaScript = PluginManager.shared.editorLanguage(for: databaseType) == .javascript - let result = await Task.detached(priority: .userInitiated) { [statements, isJavaScript] in - Self.build(statements: statements, isJavaScript: isJavaScript) + let isJavaScript = !showsStatementsVerbatim + && PluginManager.shared.editorLanguage(for: databaseType) == .javascript + let verbatim = showsStatementsVerbatim + let result = await Task.detached(priority: .userInitiated) { [statements, isJavaScript, verbatim] in + Self.build(statements: statements, isJavaScript: isJavaScript, verbatim: verbatim) }.value prepared = result } - static func build(statements: [String], databaseType: DatabaseType) -> Prepared { - let isJavaScript = PluginManager.shared.editorLanguage(for: databaseType) == .javascript - return build(statements: statements, isJavaScript: isJavaScript) + static func build(statements: [String], databaseType: DatabaseType, verbatim: Bool = false) -> Prepared { + let isJavaScript = !verbatim && PluginManager.shared.editorLanguage(for: databaseType) == .javascript + return build(statements: statements, isJavaScript: isJavaScript, verbatim: verbatim) } - nonisolated private static func build(statements: [String], isJavaScript: Bool) -> Prepared { - var full = statements - .map { $0.hasSuffix(";") ? $0 : $0 + ";" } - .joined(separator: "\n\n") + nonisolated private static func build(statements: [String], isJavaScript: Bool, verbatim: Bool) -> Prepared { + var full = verbatim + ? statements.joined(separator: "\n\n") + : statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n\n") if isJavaScript { full = convertExtendedJsonToShellSyntax(full) } let nsFull = full as NSString let fullCount = nsFull.length + /// A preview may stop early and leave the rest to Copy All. A confirmation may not: the + /// statement's `WHERE` clause can sit past any cut, and approving what you cannot see is + /// the whole of what this dialog exists to prevent. `execute_query` accepts 102,400 units, + /// which the text view below renders and a SwiftUI `Text` does not. + if verbatim { + return Prepared( + display: full, + full: full, + mode: fullCount <= treeSitterCutoff ? .rich : .plain + ) + } if fullCount > maxDisplayChars { let head = nsFull.substring(to: maxDisplayChars) let remaining = fullCount - maxDisplayChars @@ -148,31 +206,47 @@ struct SQLReviewSheet: View { } private var header: some View { - HStack(spacing: 8) { - Text("\(PluginManager.shared.queryLanguageName(for: databaseType)) Preview") - .font(.body.weight(.semibold)) - if !statements.isEmpty { - Text( - "(\(statements.count) \(statements.count == 1 ? String(localized: "statement") : String(localized: "statements")))" - ) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - if !statements.isEmpty { - Button(action: copyAll) { - Label( - copied ? String(localized: "Copied") : String(localized: "Copy All"), - systemImage: copied ? "checkmark" : "doc.on.doc" + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(title ?? defaultTitle) + .font(.body.weight(.semibold)) + if !statements.isEmpty { + Text( + "(\(statements.count) \(statements.count == 1 ? String(localized: "statement") : String(localized: "statements")))" ) + .font(.subheadline) + .foregroundStyle(.secondary) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(prepared == nil) + Spacer() + if !statements.isEmpty { + Button(action: copyAll) { + Label( + copied ? String(localized: "Copied") : String(localized: "Copy All"), + systemImage: copied ? "checkmark" : "doc.on.doc" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(prepared == nil) + } + } + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } } } + private var defaultTitle: String { + String( + format: String(localized: "%@ Preview"), + PluginManager.shared.queryLanguageName(for: databaseType) + ) + } + private var emptyState: some View { VStack(spacing: 8) { Image(systemName: "doc.plaintext") @@ -214,22 +288,17 @@ struct SQLReviewSheet: View { ) } + /// A text view rather than a `Text` in a `ScrollView`: this path carries everything past the + /// tree-sitter cutoff, which for a confirmation is the whole statement however long it is, and + /// a single `Text` that size lays out for seconds. It wears the editor's own background, text + /// colour and syntax palette, which is what a hand-themed `Text` here was reaching for. private func plainTextEditor(_ text: String) -> some View { - ScrollView(.vertical) { - Text(text) - .font(.system(size: 12, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - } - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) + HighlightedSQLTextView(sql: text, databaseType: databaseType) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) } @ViewBuilder @@ -265,12 +334,12 @@ struct SQLReviewSheet: View { ProgressView().controlSize(.small) } if let primaryAction { - Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + Button(String(localized: "Cancel"), role: .cancel) { isPresented = false } .keyboardShortcut(.cancelAction) .disabled(isExecuting) executeButton(primaryAction) } else { - Button(String(localized: "Done")) { dismiss() } + Button(String(localized: "Done")) { isPresented = false } .keyboardShortcut(.cancelAction) } } @@ -280,16 +349,21 @@ struct SQLReviewSheet: View { @ViewBuilder private func executeButton(_ action: PrimaryAction) -> some View { let button = Button(action.title, role: action.isDestructive ? .destructive : nil) { - isExecuting = true - Task { - await action.perform() - isExecuting = false + switch action.work { + case .immediate(let perform): + perform() + case .asynchronous(let perform): + isExecuting = true + Task { + await perform() + isExecuting = false + } } } - .disabled(statements.isEmpty || isExecuting) + .disabled(statements.isEmpty || isExecuting || prepared == nil) .accessibilityIdentifier("sql-review-execute") - if action.isDestructive { + if action.isDestructive || !action.takesDefaultAction { button } else { button.keyboardShortcut(.defaultAction) diff --git a/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift b/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift index e8cb0ebde2..c11b033544 100644 --- a/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift +++ b/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift @@ -13,7 +13,7 @@ internal struct FileModifiedOnDiskBanner: View { var body: some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .accessibilityHidden(true) Text(String(format: String(localized: "\"%@\" was modified on disk."), fileName)) @@ -41,6 +41,6 @@ internal struct FileModifiedOnDiskBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.yellow.opacity(0.12)) + .background(ThemeEngine.shared.palette.color(.statusWarning).opacity(0.12)) } } diff --git a/TablePro/Views/Editor/Folding/FoldPreviewView.swift b/TablePro/Views/Editor/Folding/FoldPreviewView.swift index 3648993cc8..65f856410d 100644 --- a/TablePro/Views/Editor/Folding/FoldPreviewView.swift +++ b/TablePro/Views/Editor/Folding/FoldPreviewView.swift @@ -51,7 +51,7 @@ struct FoldPreviewView: View { .padding(.vertical, 5) } } - .background(ThemeEngine.shared.colors.editor.backgroundSwiftUI) + .background(ThemeEngine.shared.palette.color(.editorBackground)) .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) diff --git a/TablePro/Views/Editor/History/HistoryDetailPane.swift b/TablePro/Views/Editor/History/HistoryDetailPane.swift index 2e6f10a27c..30cc5ba1a0 100644 --- a/TablePro/Views/Editor/History/HistoryDetailPane.swift +++ b/TablePro/Views/Editor/History/HistoryDetailPane.swift @@ -37,7 +37,7 @@ struct HistoryDetailPane: View { databaseType: entry.databaseType, accessibilityIdentifier: "query-history-detail-query" ) - .background(Color(nsColor: ThemeEngine.shared.colors.editor.background)) + .background(ThemeEngine.shared.palette.color(.editorBackground)) .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() @@ -66,7 +66,7 @@ struct HistoryDetailPane: View { if let errorMessage = entry.errorMessage { RevealedTextView(errorMessage) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } diff --git a/TablePro/Views/Editor/History/HistoryRowView.swift b/TablePro/Views/Editor/History/HistoryRowView.swift index 37151159af..7bc2ac4603 100644 --- a/TablePro/Views/Editor/History/HistoryRowView.swift +++ b/TablePro/Views/Editor/History/HistoryRowView.swift @@ -71,7 +71,7 @@ struct HistoryRowView: View { .foregroundStyle(Color.secondary) } else { Image(systemName: "exclamationmark.circle.fill") - .selectionAwareTint(.red) + .selectionAwareTint(ThemeEngine.shared.palette.color(.statusError)) } } diff --git a/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index 915c16e2c0..a87138ec7d 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -222,7 +222,10 @@ final class SQLSuggestionEntry: CodeSuggestionEntry { Image(systemName: item.kind.iconName) } + /// The suggestion protocol is nonisolated and the panel only ever reads this on the main + /// thread. The kind is lifted out first so the closure sends a value rather than `self`. var imageColor: Color { - Color(nsColor: item.kind.iconColor) + let kind = item.kind + return MainActor.assumeIsolated { Color(nsColor: kind.iconColor) } } } diff --git a/TablePro/Views/Editor/QueryDiagnosticsController.swift b/TablePro/Views/Editor/QueryDiagnosticsController.swift index 5f4237932f..ce15ccb4ed 100644 --- a/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -75,6 +75,14 @@ final class QueryDiagnosticsController { apply(produced, in: controller) } + /// An emphasis bakes its colour into a `CAShapeLayer`, so a theme change inside one appearance + /// leaves an existing underline on the previous colour. `refresh(for:)` cannot repaint it: the + /// diagnostics themselves have not changed, so it returns early. + func reapplyColors(in controller: TextViewController) { + guard !diagnostics.isEmpty else { return } + apply(diagnostics, in: controller) + } + func clear(in controller: TextViewController?) { pendingTask?.cancel() pendingTask = nil @@ -104,8 +112,8 @@ final class QueryDiagnosticsController { private func color(for severity: QueryDiagnostic.Severity) -> NSColor { switch severity { - case .error: return .systemRed - case .warning: return .systemOrange + case .error: return ThemeEngine.shared.palette[.statusError] + case .warning: return ThemeEngine.shared.palette[.statusWarning] } } } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index aa6bfd91b3..6ca5dcc3b0 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -97,7 +97,6 @@ struct QueryEditorView: View { .frame(minHeight: 100) .clipped() } - .background(Color(nsColor: .textBackgroundColor)) } // MARK: - Toolbar diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index ebbab9e1ed..4a92746fe6 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -27,6 +27,13 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters @ObservationIgnored weak var controller: TextViewController? + + /// The editor configuration carries the new colours, but an emphasis already on screen baked + /// its own into a `CAShapeLayer` that nothing else repaints. + func reapplyThemeColors() { + guard let controller else { return } + diagnosticsController.reapplyColors(in: controller) + } @ObservationIgnored private lazy var diagnosticsController = QueryDiagnosticsController( databaseType: databaseType ) diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 028a487225..022862a380 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -147,6 +147,7 @@ struct SQLEditorView: View { } .onReceive(AppEvents.shared.themeChanged) { _ in editorConfiguration = Self.makeConfiguration() + coordinator.reapplyThemeColors() } .onAppear { initializeEditor() @@ -248,17 +249,17 @@ struct SQLEditorView: View { appearance: .init( theme: TableProEditorTheme.make(), font: ThemeEngine.shared.editorFonts.font, - wrapLines: ThemeEngine.shared.wordWrap, - tabWidth: ThemeEngine.shared.tabWidth + wrapLines: AppSettingsManager.shared.editor.wordWrap, + tabWidth: AppSettingsManager.shared.editor.clampedTabWidth ), behavior: .init( - indentOption: .spaces(count: ThemeEngine.shared.tabWidth) + indentOption: .spaces(count: AppSettingsManager.shared.editor.clampedTabWidth) ), layout: .init( contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) ), peripherals: EditorPeripherals.editor( - lineNumbers: ThemeEngine.shared.showLineNumbers, + lineNumbers: AppSettingsManager.shared.editor.showLineNumbers, folding: AppSettingsManager.shared.editor.codeFoldingEnabled, statementRunControls: AppSettingsManager.shared.editor.showStatementRunControls, invisibleCharacters: AppSettingsManager.shared.editor.showInvisibleCharacters diff --git a/TablePro/Views/Highlight/HighlightColor+AppKit.swift b/TablePro/Views/Highlight/HighlightColor+AppKit.swift new file mode 100644 index 0000000000..fb3bc5b7d6 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightColor+AppKit.swift @@ -0,0 +1,42 @@ +// +// HighlightColor+AppKit.swift +// TablePro +// + +import AppKit + +extension HighlightColor { + static let washAlpha: CGFloat = 0.2 + + var systemColor: NSColor { + switch self { + case .red: return .systemRed + case .orange: return .systemOrange + case .yellow: return .systemYellow + case .green: return .systemGreen + case .blue: return .systemBlue + case .purple: return .systemPurple + case .gray: return .systemGray + } + } + + var washColor: NSColor { + systemColor.withAlphaComponent(Self.washAlpha) + } + + func swatchImage(diameter: CGFloat = 12) -> NSImage { + let color = systemColor + let image = NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)).fill() + NSColor.separatorColor.setStroke() + let outline = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + outline.lineWidth = 0.5 + outline.stroke() + return true + } + image.isTemplate = false + image.accessibilityDescription = displayName + return image + } +} diff --git a/TablePro/Views/Highlight/HighlightColumnOption.swift b/TablePro/Views/Highlight/HighlightColumnOption.swift new file mode 100644 index 0000000000..945e8fb854 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightColumnOption.swift @@ -0,0 +1,31 @@ +// +// HighlightColumnOption.swift +// TablePro +// + +import Foundation + +struct HighlightColumnOption: Identifiable, Hashable { + let name: String + let occurrence: Int + let label: String + + var id: String { Self.identifier(name: name, occurrence: occurrence) } + + static func identifier(name: String, occurrence: Int) -> String { + "\(occurrence)#\(name)" + } + + static func options(for columns: [String]) -> [HighlightColumnOption] { + var seen: [String: Int] = [:] + let totals = columns.reduce(into: [String: Int]()) { $0[$1, default: 0] += 1 } + return columns.map { name in + let occurrence = seen[name, default: 0] + seen[name] = occurrence + 1 + let label = totals[name, default: 0] > 1 + ? String(format: String(localized: "%1$@ (%2$d)"), name, occurrence + 1) + : name + return HighlightColumnOption(name: name, occurrence: occurrence, label: label) + } + } +} diff --git a/TablePro/Views/Highlight/HighlightMenuBuilder.swift b/TablePro/Views/Highlight/HighlightMenuBuilder.swift new file mode 100644 index 0000000000..537505cbba --- /dev/null +++ b/TablePro/Views/Highlight/HighlightMenuBuilder.swift @@ -0,0 +1,144 @@ +// +// HighlightMenuBuilder.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +@MainActor +enum HighlightMenuBuilder { + struct CellContext { + let columnName: String + let columnOccurrence: Int + let columnType: ColumnType? + let value: PluginCellValue + let existingRules: [HighlightRule] + } + + struct Actions { + let apply: (HighlightRule) -> Void + let remove: (HighlightRule) -> Void + let showRules: () -> Void + } + + static func quickRule( + columnName: String, + columnOccurrence: Int, + columnType: ColumnType?, + value: PluginCellValue, + target: HighlightTarget, + color: HighlightColor + ) -> HighlightRule? { + switch value { + case .null: + return HighlightRule( + columnName: columnName, + columnOccurrence: columnOccurrence, + filterOperator: .isNull, + color: color, + target: target + ) + case .text(let text) where text.isEmpty: + return HighlightRule( + columnName: columnName, + columnOccurrence: columnOccurrence, + filterOperator: .isEmpty, + color: color, + target: target + ) + case .text(let text) where HighlightCondition.readsAsNullLiteral(text, columnType: columnType): + return nil + case .text(let text): + return HighlightRule( + columnName: columnName, + columnOccurrence: columnOccurrence, + filterOperator: .equal, + value: text, + color: color, + target: target + ) + case .bytes: + return nil + } + } + + static func sectionTitle(for rule: HighlightRule) -> String { + let condition = HighlightRuleDescription.condition( + of: rule, + valueLimit: HighlightRuleDescription.menuValueLimit + ) + switch rule.target { + case .row: + return String(format: String(localized: "Rows Where %@"), condition) + case .cell: + return String(format: String(localized: "Cells Where %@"), condition) + } + } + + static func menuItem(for context: CellContext, actions: Actions) -> NSMenuItem? { + let templates = HighlightTarget.allCases.compactMap { target in + quickRule( + columnName: context.columnName, + columnOccurrence: context.columnOccurrence, + columnType: context.columnType, + value: context.value, + target: target, + color: .yellow + ) + } + guard !templates.isEmpty else { return nil } + + let submenu = NSMenu() + var existingMatches: [HighlightRule] = [] + for template in templates { + let existing = context.existingRules.first { $0.hasSameCondition(as: template) } + if let existing { existingMatches.append(existing) } + submenu.addItem(.sectionHeader(title: sectionTitle(for: template))) + submenu.addItem(paletteItem(for: template, existing: existing, actions: actions)) + } + + submenu.addItem(.separator()) + if !existingMatches.isEmpty { + submenu.addItem(ClosureMenuTarget.item(title: String(localized: "Remove Highlight")) { + existingMatches.forEach(actions.remove) + }) + } + submenu.addItem(ClosureMenuTarget.item(title: String(localized: "Highlight Rules…"), action: actions.showRules)) + + let item = NSMenuItem(title: String(localized: "Highlight"), action: nil, keyEquivalent: "") + item.image = NSImage(systemSymbolName: "highlighter", accessibilityDescription: nil) + item.submenu = submenu + return item + } + + private static func paletteItem( + for template: HighlightRule, + existing: HighlightRule?, + actions: Actions + ) -> NSMenuItem { + let colors = HighlightColor.allCases + let palette = NSMenu.palette( + colors: colors.map(\.systemColor), + titles: colors.map(\.displayName) + ) { menu in + let selected = menu.selectedItems.compactMap { menu.items.firstIndex(of: $0) } + guard let index = selected.first, colors.indices.contains(index) else { + if let existing { actions.remove(existing) } + return + } + var rule = existing ?? template + rule.color = colors[index] + rule.isEnabled = true + actions.apply(rule) + } + palette.selectionMode = .selectOne + if let existing, let index = colors.firstIndex(of: existing.color), index < palette.items.count { + palette.selectedItems = [palette.items[index]] + } + + let item = NSMenuItem(title: sectionTitle(for: template), action: nil, keyEquivalent: "") + item.submenu = palette + return item + } +} diff --git a/TablePro/Views/Highlight/HighlightRuleRow.swift b/TablePro/Views/Highlight/HighlightRuleRow.swift new file mode 100644 index 0000000000..7e07625e35 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightRuleRow.swift @@ -0,0 +1,224 @@ +// +// HighlightRuleRow.swift +// TablePro +// + +import SwiftUI + +struct HighlightRuleRow: View { + @Binding var rule: HighlightRule + let columnOptions: [HighlightColumnOption] + @Binding var focusedRuleID: UUID? + let canMoveUp: Bool + let canMoveDown: Bool + let onMoveUp: () -> Void + let onMoveDown: () -> Void + let onRemove: () -> Void + let onCancel: () -> Void + + private var isColumnMissing: Bool { + !columnOptions.contains { $0.name == rule.columnName && $0.occurrence == rule.columnOccurrence } + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Toggle("", isOn: $rule.isEnabled) + .toggleStyle(.checkbox) + .labelsHidden() + .accessibilityLabel(String(localized: "Enable rule")) + .accessibilityIdentifier("highlight-rule-enabled") + .help(String(localized: "Apply this rule")) + conditionEditor + .opacity(rule.isEnabled ? 1 : 0.5) + } + HStack(spacing: 8) { + colorPicker + targetPicker + if isColumnMissing { + Label(String(localized: "Not in this result"), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .help(String(format: String(localized: "This result has no column named %@"), rule.columnName)) + } + Spacer(minLength: 0) + removeButton + } + .padding(.leading, 22) + .opacity(rule.isEnabled ? 1 : 0.5) + } + .padding(.vertical, 4) + .accessibilityElement(children: .contain) + .accessibilityLabel(HighlightRuleDescription.condition(of: rule)) + .accessibilityActions { + if canMoveUp { + Button(String(localized: "Move Rule Up"), action: onMoveUp) + } + if canMoveDown { + Button(String(localized: "Move Rule Down"), action: onMoveDown) + } + } + } + + private var conditionEditor: some View { + HStack(spacing: 6) { + columnPicker + operatorMenu + valueFields + } + } + + private var columnSelection: Binding { + Binding( + get: { HighlightColumnOption.identifier(name: rule.columnName, occurrence: rule.columnOccurrence) }, + set: { identifier in + guard let option = columnOptions.first(where: { $0.id == identifier }) else { return } + rule.columnName = option.name + rule.columnOccurrence = option.occurrence + } + ) + } + + private var columnPicker: some View { + Picker("", selection: columnSelection) { + ForEach(columnOptions) { option in + Text(option.label).tag(option.id) + } + if isColumnMissing { + Divider() + Text(rule.columnName) + .tag(HighlightColumnOption.identifier(name: rule.columnName, occurrence: rule.columnOccurrence)) + } + } + .pickerStyle(.menu) + .controlSize(.small) + .fixedSize() + .labelsHidden() + .accessibilityLabel(String(localized: "Rule column")) + .accessibilityValue(rule.columnName) + } + + private var operatorSelection: Binding { + Binding( + get: { rule.filterOperator }, + set: { newOperator in + guard newOperator != rule.filterOperator else { return } + rule.filterOperator = newOperator + rule.isCaseSensitive = newOperator.defaultIsCaseSensitive + } + ) + } + + private var operatorMenu: some View { + Menu { + Picker("", selection: operatorSelection) { + ForEach(FilterOperator.allCases) { filterOperator in + Text(Self.operatorLabel(filterOperator)) + .accessibilityLabel(filterOperator.displayName) + .tag(filterOperator) + } + } + .pickerStyle(.inline) + .labelsHidden() + + if rule.filterOperator.supportsCaseSensitivity { + Divider() + Toggle(String(localized: "Match Case"), isOn: $rule.isCaseSensitive) + } + } label: { + HStack(spacing: 3) { + Text(Self.operatorLabel(rule.filterOperator)) + if rule.filterOperator.supportsCaseSensitivity, + rule.isCaseSensitive != rule.filterOperator.defaultIsCaseSensitive { + Image(systemName: "textformat") + .imageScale(.small) + .foregroundStyle(.secondary) + } + } + } + .menuStyle(.button) + .controlSize(.small) + .fixedSize() + .accessibilityLabel(String(localized: "Rule operator")) + .accessibilityValue(rule.filterOperator.displayName) + } + + @ViewBuilder + private var valueFields: some View { + if rule.filterOperator.requiresValue { + FilterValueTextField( + text: $rule.value, + focusedId: $focusedRuleID, + identity: rule.id, + placeholder: String(localized: "Value"), + onCancel: onCancel + ) + .frame(minWidth: 90) + .accessibilityLabel(String(localized: "Rule value")) + + if rule.filterOperator.requiresSecondValue { + Text("and") + .font(.subheadline) + .foregroundStyle(.secondary) + TextField("Value", text: Binding( + get: { rule.secondValue ?? "" }, + set: { rule.secondValue = $0 } + )) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + .autocorrectionDisabled(true) + .frame(minWidth: 70) + .accessibilityLabel(String(localized: "Second rule value")) + } + } else { + Spacer(minLength: 0) + } + } + + private var colorPicker: some View { + Picker("", selection: $rule.color) { + ForEach(HighlightColor.allCases) { color in + Label { + Text(color.displayName) + } icon: { + Image(nsImage: color.swatchImage()) + } + .tag(color) + } + } + .pickerStyle(.menu) + .controlSize(.small) + .fixedSize() + .labelsHidden() + .accessibilityLabel(String(localized: "Highlight color")) + .accessibilityValue(rule.color.displayName) + } + + private var targetPicker: some View { + Picker("", selection: $rule.target) { + ForEach(HighlightTarget.allCases) { target in + Text(target.displayName).tag(target) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + .labelsHidden() + .accessibilityLabel(String(localized: "Apply To")) + .help(String(localized: "Color the whole row, or only the matching cell")) + } + + private var removeButton: some View { + Button(String(localized: "Remove Rule"), systemImage: "minus", action: onRemove) + .labelStyle(.iconOnly) + .buttonStyle(.bordered) + .controlSize(.small) + .help(String(localized: "Remove this rule")) + } + + private static func operatorLabel(_ filterOperator: FilterOperator) -> String { + filterOperator.symbol.isEmpty + ? filterOperator.displayName + : "\(filterOperator.symbol) \(filterOperator.displayName)" + } +} diff --git a/TablePro/Views/Highlight/HighlightRulesPopover.swift b/TablePro/Views/Highlight/HighlightRulesPopover.swift new file mode 100644 index 0000000000..642004f9c0 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightRulesPopover.swift @@ -0,0 +1,144 @@ +// +// HighlightRulesPopover.swift +// TablePro +// + +import SwiftUI + +struct HighlightRulesPopover: View { + let columns: [String] + let rules: [HighlightRule] + let isPersisted: Bool + let onChange: ([HighlightRule]) -> Void + + @State private var focusedRuleID: UUID? + @Environment(\.dismiss) private var dismiss + + private static let rowHeight: CGFloat = 64 + private static let maximumListHeight: CGFloat = 420 + + private var columnOptions: [HighlightColumnOption] { + HighlightColumnOption.options(for: columns) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + if rules.isEmpty { + emptyState + } else { + ruleList + } + Divider() + footer + } + .frame(width: 540) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Highlight Rules") + .font(.headline) + Text("Rules are checked in order. The first match sets the color.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var emptyState: some View { + ContentUnavailableView { + Label(String(localized: "No Highlight Rules"), systemImage: "highlighter") + } description: { + Text("Right-click a cell and choose Highlight to color rows by value.") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private var ruleList: some View { + List { + ForEach(rules) { rule in + HighlightRuleRow( + rule: binding(for: rule), + columnOptions: columnOptions, + focusedRuleID: $focusedRuleID, + canMoveUp: rules.first?.id != rule.id, + canMoveDown: rules.last?.id != rule.id, + onMoveUp: { move(rule, by: -1) }, + onMoveDown: { move(rule, by: 1) }, + onRemove: { remove(rule) }, + onCancel: close + ) + } + .onMove(perform: move) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .frame(height: min(CGFloat(rules.count) * Self.rowHeight + 8, Self.maximumListHeight)) + } + + private var footer: some View { + HStack(spacing: 8) { + Button(String(localized: "Add Rule"), systemImage: "plus", action: addRule) + .controlSize(.small) + .disabled(columns.isEmpty) + .accessibilityIdentifier("highlight-rules-add") + + Spacer(minLength: 8) + + if !isPersisted { + Text("Rules for this query result are not saved.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func binding(for rule: HighlightRule) -> Binding { + Binding( + get: { rules.first { $0.id == rule.id } ?? rule }, + set: { updated in + var next = rules + guard let index = next.firstIndex(where: { $0.id == updated.id }) else { return } + next[index] = updated + onChange(next) + } + ) + } + + private func addRule() { + guard let first = columnOptions.first else { return } + let rule = HighlightRule(columnName: first.name, columnOccurrence: first.occurrence) + onChange(rules + [rule]) + focusedRuleID = rule.id + } + + private func close() { + dismiss() + } + + private func remove(_ rule: HighlightRule) { + onChange(rules.filter { $0.id != rule.id }) + } + + private func move(from source: IndexSet, to destination: Int) { + var next = rules + next.move(fromOffsets: source, toOffset: destination) + onChange(next) + } + + private func move(_ rule: HighlightRule, by offset: Int) { + guard let index = rules.firstIndex(where: { $0.id == rule.id }) else { return } + let target = index + offset + guard rules.indices.contains(target) else { return } + var next = rules + next.swapAt(index, target) + onChange(next) + } +} diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index d9c7fac35b..3047047ca5 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -279,7 +279,7 @@ struct ImportDialog: View { Picker(String(localized: "Encoding"), selection: $selectedEncoding) { ForEach(ImportEncoding.allCases) { enc in - Text(enc.rawValue).tag(enc) + Text(enc.label).tag(enc) } } .pickerStyle(.menu) @@ -421,11 +421,12 @@ struct ImportDialog: View { let maxPreviewSize = 5 * 1_024 * 1_024 let previewData = handle.readData(ofLength: maxPreviewSize) - if let preview = String(data: previewData, encoding: selectedEncoding.encoding) { + var decoder = SQLChunkDecoder(encoding: selectedEncoding.encoding) + if let preview = decoder.decode(previewData) { filePreview = preview hasPreviewError = false } else { - filePreview = String(format: String(localized: "Failed to load preview using encoding: %@. Try selecting a different text encoding."), selectedEncoding.rawValue) + filePreview = String(format: String(localized: "Failed to load preview using encoding: %@. Try selecting a different text encoding."), selectedEncoding.label) hasPreviewError = true } } catch { diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index f82000f538..85fbb6cfe5 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -724,12 +724,17 @@ struct RowImportSheet: View { defer { isLoadingContext = false } do { let fields = try await Self.detectFields(plugin: plugin, at: fileURL, targetTable: nil) + let serverVersion = DatabaseManager.shared.driver(for: connection.id)?.serverVersion newColumns = fields.map { field in NewColumn( field: field, include: true, name: field.name, - type: ImportTypeMapper.sqlType(for: field.inferredType, databaseType: connection.type), + type: ImportTypeMapper.sqlType( + for: field.inferredType, + databaseType: connection.type, + serverVersion: serverVersion + ), isPrimaryKey: false, isNullable: true, defaultValue: "" diff --git a/TablePro/Views/Import/SQLCodePreview.swift b/TablePro/Views/Import/SQLCodePreview.swift index f3472133fe..669a10ca0e 100644 --- a/TablePro/Views/Import/SQLCodePreview.swift +++ b/TablePro/Views/Import/SQLCodePreview.swift @@ -19,7 +19,7 @@ struct SQLCodePreview: View { var body: some View { if text.isEmpty { - Color(nsColor: .textBackgroundColor) + ThemeEngine.shared.palette.color(.editorBackground) } else { SourceEditor( $text, @@ -31,6 +31,9 @@ struct SQLCodePreview: View { .onChange(of: colorScheme) { editorConfiguration = Self.makeConfiguration() } + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration() + } } } diff --git a/TablePro/Views/Inspector/InspectorChangeManager.swift b/TablePro/Views/Inspector/InspectorChangeManager.swift index 992460f11d..ca11f7f4ba 100644 --- a/TablePro/Views/Inspector/InspectorChangeManager.swift +++ b/TablePro/Views/Inspector/InspectorChangeManager.swift @@ -13,12 +13,12 @@ final class InspectorChangeManager: ChangeManaging { var hasChanges: Bool { false } var canRedo: Bool { false } var rowChanges: [RowChange] { [] } - var insertedRowIndices: Set { [] } + var insertedRowIDs: Set { [] } - func isRowDeleted(_ rowIndex: Int) -> Bool { false } + func isRowDeleted(_ rowID: RowID) -> Bool { false } func recordCellChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, oldValue: PluginCellValue, @@ -26,8 +26,7 @@ final class InspectorChangeManager: ChangeManaging { originalRow: [PluginCellValue]? ) {} - func undoRowDeletion(rowIndex: Int) {} - func undoRowInsertion(rowIndex: Int) {} + func undoRowDeletion(rowID: RowID) {} func bumpReload() { reloadVersion &+= 1 diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 4de563328c..4a06441cda 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -18,7 +18,6 @@ final class DataTabGridDelegate: DataGridViewDelegate { var onCellEdit: ((Int, Int, String?) -> Void)? var onSortStateChanged: ((SortState) -> Void)? var onAddRow: (() -> Void)? - var onUndoInsert: ((Int) -> Void)? var onFilterColumn: ((String) -> Void)? // MARK: - DataGridViewDelegate @@ -56,10 +55,6 @@ final class DataTabGridDelegate: DataGridViewDelegate { onAddRow?() } - func dataGridUndoInsert(at index: Int) { - onUndoInsert?(index) - } - func dataGridFilterColumn(_ columnName: String) { onFilterColumn?(columnName) } @@ -134,6 +129,48 @@ final class DataTabGridDelegate: DataGridViewDelegate { return menu } + func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { + guard let coordinator, + let grid = tableViewCoordinator, + let tab = coordinator.tabManager.selectedTab, + let row = grid.displayRow(at: displayRow) else { return nil } + let tableRows = grid.tableRowsProvider() + let columns = tableRows.columns + guard columns.indices.contains(dataColumn), dataColumn < row.values.count else { return nil } + + let tabId = tab.id + let context = HighlightMenuBuilder.CellContext( + columnName: columns[dataColumn], + columnOccurrence: HighlightRuleSet.occurrence(ofColumnAt: dataColumn, in: columns), + columnType: dataColumn < tableRows.columnTypes.count ? tableRows.columnTypes[dataColumn] : nil, + value: row.values[dataColumn], + existingRules: coordinator.highlightRules(for: tab) + ) + let actions = HighlightMenuBuilder.Actions( + apply: { [weak coordinator] rule in + coordinator?.applyQuickHighlight(rule, forTab: tabId) + }, + remove: { [weak coordinator] rule in + coordinator?.removeHighlightRules(sharingConditionWith: rule, forTab: tabId) + }, + showRules: { [weak coordinator] in + coordinator?.presentHighlightRules() + } + ) + return HighlightMenuBuilder.menuItem(for: context, actions: actions) + } + + func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? { + guard coordinator != nil, let grid = tableViewCoordinator else { return nil } + let columns = grid.tableRowsProvider().columns + guard columns.indices.contains(dataColumnIndex) else { return nil } + let columnName = columns[dataColumnIndex] + let occurrence = HighlightRuleSet.occurrence(ofColumnAt: dataColumnIndex, in: columns) + return ClosureMenuTarget.item(title: String(localized: "Highlight Values…")) { [weak coordinator] in + coordinator?.presentHighlightRules(addingRuleForColumn: columnName, occurrence: occurrence) + } + } + weak var tableViewCoordinator: TableViewCoordinator? func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bfe980b42f..c8a529da63 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -44,7 +44,6 @@ struct MainEditorContentView: View { let onCellEdit: (Int, Int, String?) -> Void let onSortStateChanged: (SortState) -> Void let onAddRow: () -> Void - let onUndoInsert: (Int) -> Void let onSelectionChange: (Set) -> Void let onFilterColumn: (String) -> Void let onApplyFilters: ([TableFilter]) -> Void @@ -205,7 +204,6 @@ struct MainEditorContentView: View { dataTabDelegate.selectionState = selectionState dataTabDelegate.onCellEdit = onCellEdit dataTabDelegate.onSortStateChanged = onSortStateChanged - dataTabDelegate.onUndoInsert = onUndoInsert dataTabDelegate.onFilterColumn = onFilterColumn } @@ -667,7 +665,6 @@ struct MainEditorContentView: View { connection: connection, databaseName: scope?.database ?? "", schemaName: scope?.schema, - isViewObject: tab.tableContext.isView, toolbarState: coordinator.toolbarState, coordinator: coordinator, selectionState: selectionState, @@ -681,7 +678,8 @@ struct MainEditorContentView: View { connection: connection, databaseName: scope?.database ?? "", schemaName: scope?.schema, - tableName: tableName + tableName: tableName, + objectKind: tab.tableContext.resolvedObjectKind() ) } } @@ -706,7 +704,7 @@ struct MainEditorContentView: View { tableRows: resolvedTableRows(for: tab), selectedRowIndices: selectionState.indices, displayIDs: coordinator.displayIDs(forTab: tab.id), - deletedRowIndices: changeManager.deletedRowIndices, + deletedRowIDs: changeManager.deletedRowIDs, valueFilter: tab.valueFilter, dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, displayRevision: coordinator.gridDisplayRevision, @@ -892,10 +890,7 @@ struct MainEditorContentView: View { coordinator.tabSessionRegistry.existingTableRows(for: tabId) ?? TableRows() }, tableRowsMutator: { [coordinator] mutate in - coordinator.mutateActiveTableRows(for: tabId) { rows in - mutate(&rows) - return .none - } + coordinator.mutateActiveTableRows(for: tabId) { rows in mutate(&rows) } }, paginationOffsetProvider: { [coordinator] in coordinator.tabManager.tabs.first(where: { $0.id == tabId })?.pagination.currentOffset ?? 0 @@ -916,6 +911,7 @@ struct MainEditorContentView: View { editRefusalMessage: refusal?.message ), displayFormats: coordinator.displayFormats(for: tab), + highlightRules: coordinator.highlightRules(for: tab), delegate: dataTabDelegate, selectedRowIndices: Binding( get: { selectionState.indices }, @@ -924,6 +920,9 @@ struct MainEditorContentView: View { sortState: sortStateBinding(for: tab), columnLayout: columnLayoutBinding(for: tab), valueFilter: valueFilterBinding(for: tab), + displayOrderProvider: { [coordinator] in + coordinator.displayIDs(forTab: tabId) + }, displayState: coordinator.displayState(for: tab), restoredRowSelection: tab.selectedRowIndices, restoredCellSelection: tab.cellSelection, @@ -1019,6 +1018,18 @@ struct MainEditorContentView: View { ? { coordinator.showColumnJump(seededWith: $0) } : nil ), + highlightState: StatusBarHighlightState( + rules: coordinator.highlightRules(for: tab), + columns: resolvedRows.columns, + isPersisted: coordinator.highlightRuleScope(for: tab) != nil, + presentationRequest: tab.display.highlightRulesPresentationRequest, + onChange: { [coordinator, tabId = tab.id] rules in + coordinator.setHighlightRules(rules, forTab: tabId) + }, + onDismiss: { [coordinator, tabId = tab.id] in + coordinator.discardIncompleteHighlightRules(forTab: tabId) + } + ), paginationCallbacks: PaginationCallbacks( onFirst: onFirstPage, onPrevious: onPreviousPage, diff --git a/TablePro/Views/Main/EditorTabContextMenuBuilder.swift b/TablePro/Views/Main/EditorTabContextMenuBuilder.swift index f06ad43bb6..214b893374 100644 --- a/TablePro/Views/Main/EditorTabContextMenuBuilder.swift +++ b/TablePro/Views/Main/EditorTabContextMenuBuilder.swift @@ -58,27 +58,6 @@ internal enum EditorTabContextMenuBuilder { isEnabled: Bool = true, action: @escaping () -> Void ) { - let item = NSMenuItem(title: title, action: #selector(ClosureMenuTarget.fire), keyEquivalent: "") - let target = ClosureMenuTarget(action: action) - item.target = target - item.representedObject = target - item.isEnabled = isEnabled - menu.addItem(item) - } -} - -/// `NSMenuItem` holds its target weakly, so the closure needs an owner that outlives the menu. -/// `representedObject` is that owner: it is strong, it belongs to the item, and it goes when the -/// item does. -@MainActor -private final class ClosureMenuTarget: NSObject { - private let action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - @objc func fire() { - action() + menu.addItem(ClosureMenuTarget.item(title: title, isEnabled: isEnabled, action: action)) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift index 7eec831fdf..b99c3504b0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift @@ -48,8 +48,7 @@ extension MainContentCoordinator { case .navigation: return String(localized: "Moving through this tab's history replaces what it is showing and will discard all unsaved changes.") /// No mention of reloading, unlike sort and the WHERE filter: a value filter narrows the - /// rows already loaded and never re-queries. What it does change is which row each display - /// position names, which is what the edits are recorded against. + /// rows already loaded and never re-queries. case .displayOrder: return String(localized: "Changing which rows are shown will discard all unsaved changes.") /// Named for the reload, like sort and the WHERE filter, because that is what costs the diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift new file mode 100644 index 0000000000..036b4ce68e --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift @@ -0,0 +1,157 @@ +// +// MainContentCoordinator+DatabaseObjectTools.swift +// TablePro +// +// Copy DDL, Refresh Materialized View and Edit Comment on one table-like object. +// + +import AppKit +import Foundation +import os +import TableProPluginKit + +extension MainContentCoordinator { + private static let objectToolsLogger = Logger(subsystem: "com.TablePro", category: "DatabaseObjectTools") + + /// The object a sidebar row or the menu bar's selection names, scoped to its own database and + /// schema. A row whose database is nil belongs to the database being browsed. + func objectTarget(for ref: DatabaseTreeTableRef) -> DatabaseObjectTarget? { + guard let scope = services.databaseManager.resolvedScope( + database: ref.database, + schema: ref.qualifyingSchema, + for: connectionId + ) else { return nil } + return DatabaseObjectTarget( + name: ref.table.name, + type: ref.table.type, + schema: scope.schema ?? ref.qualifyingSchema, + scope: scope + ) + } + + // MARK: - Copy DDL + + /// The same text the DDL viewer and the Structure tab's DDL show, read in the object's own scope. + func copyDDL(of ref: DatabaseTreeTableRef) { + guard let target = objectTarget(for: ref) else { return } + let name = target.name + Task { + do { + let ddl = try await services.databaseManager.withMetadataDriver(scope: target.scope) { driver in + try await TableDDLComposer.fetchDDL(for: name, using: driver, includesDependencies: true) + } + ClipboardService.shared.writeText(ddl) + } catch { + Self.objectToolsLogger.error("Copy DDL failed: \(error.localizedDescription, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Couldn't Copy DDL"), + message: error.localizedDescription, + window: contentWindow + ) + } + } + } + + // MARK: - Refresh Materialized View + + func refreshMaterializedView(_ ref: DatabaseTreeTableRef) { + guard !safeModeLevel.blocksAllWrites, let target = objectTarget(for: ref) else { return } + Task { + let prompt = await refreshPrompt(for: target) + MaterializedViewRefreshAlert.present(prompt: prompt, window: contentWindow) { [weak self] concurrently in + guard let self, let concurrently else { return } + self.runMaterializedViewRefresh(target, concurrently: concurrently) + } + } + } + + /// A failed check still asks the question: the refresh itself does not depend on it, and a + /// server that could not answer the catalog read will say why when the refresh runs. + private func refreshPrompt(for target: DatabaseObjectTarget) async -> MaterializedViewRefreshPrompt { + do { + let availability = try await MaterializedViewRefreshing.concurrentRefreshAvailability(of: target) + return MaterializedViewRefreshPrompt(qualifiedName: target.qualifiedName, availability: availability) + } catch { + Self.objectToolsLogger.error( + "Concurrent refresh check failed: \(error.localizedDescription, privacy: .public)" + ) + return MaterializedViewRefreshPrompt( + qualifiedName: target.qualifiedName, + availability: nil, + availabilityCheckFailed: true + ) + } + } + + private func runMaterializedViewRefresh(_ target: DatabaseObjectTarget, concurrently: Bool) { + Task { + do { + try await MaterializedViewRefreshing.refresh(target, concurrently: concurrently, connection: connection) + AlertHelper.showInfoSheet( + title: String(localized: "Materialized View Refreshed"), + message: String(format: String(localized: "“%@” now holds the current result of its query."), target.qualifiedName), + window: contentWindow + ) + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "Couldn't Refresh Materialized View"), + message: error.localizedDescription, + window: contentWindow + ) + } + } + } + + // MARK: - Edit Comment + + func editComment(of ref: DatabaseTreeTableRef) { + guard !safeModeLevel.blocksAllWrites, let target = objectTarget(for: ref) else { return } + activeSheet = .editObjectComment(target) + } + + // MARK: - Object Changes + + /// Brings every tab showing the changed object up to date, and no other tab. The selected one + /// reloads now, asking first if it holds edits; a background one drops its rows so it reloads + /// when it is next shown. + /// + /// The selected tab goes through `handleRefresh`, the entry Cmd+R uses, rather than straight to + /// the data reload: that one refuses to run while the Structure pane is in front and refreshes + /// the structure instead, and a tab excluded from the eviction loop for being selected would + /// otherwise keep its rows with nothing left to reload them. + func applyObjectChange( + _ change: DatabaseObjectChange, + hasPendingTableOps: Bool, + onDiscard: @escaping () -> Void + ) { + guard change.connectionId == connectionId else { return } + let showing = tabManager.tabs.filter { tab in + tab.tabType == .table && change.matches( + tableName: tab.tableContext.tableName, + databaseName: tab.tableContext.resolvedDatabaseName(browsing: browseDatabaseName), + schemaName: tab.tableContext.schemaName + ) + } + let selected = tabManager.selectedTab.flatMap { tab in showing.contains { $0.id == tab.id } ? tab : nil } + + switch change.kind { + case .rows: + for tab in showing where tab.id != selected?.id { + evictReloadableTableRows(for: tab.id) + } + if selected != nil { + handleRefresh(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) + } + case .comment: + for tab in showing { + tableMetadataCache.removeValue(forKey: tab.id) + } + if let selected, let tableName = selected.tableContext.tableName { + Task { await loadTableMetadata(tableName: tableName, for: selected) } + } + if change.scope.database == browseDatabaseName { + Task { await refreshTables() } + } + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift index d7700dfb97..5d09b5b55d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift @@ -11,21 +11,24 @@ import TableProPluginKit /// A resolved display order plus the inputs it was resolved from. /// -/// `dataRevision` is ticked by `TabSessionRegistry` on every row mutation, so a stale entry cannot -/// survive a change to the rows it was computed over. That is the point of stamping rather than -/// asking callers to remember a refresh. +/// `rowSetRevision` is ticked by `TabSessionRegistry` whenever rows arrive, leave or are replaced, +/// and never for a cell edit. An edit that takes a row out of the filter's match therefore leaves it +/// where it is until one of the three inputs here moves, which is what the grid shows, and every +/// reader that maps a display position through this order lands on the row the grid has at that +/// position. The display formats are one of those inputs because the filter matches formatted text, +/// so changing a column's format re-resolves the order the way changing the filter does. struct DisplayOrderCacheEntry { - let dataRevision: Int + let rowSetRevision: Int let valueFilter: GridValueFilterState let displayFormats: [ValueDisplayFormat?] let displayIDs: [RowID]? func matches( - dataRevision: Int, + rowSetRevision: Int, valueFilter: GridValueFilterState, displayFormats: [ValueDisplayFormat?] ) -> Bool { - self.dataRevision == dataRevision + self.rowSetRevision == rowSetRevision && self.valueFilter == valueFilter && self.displayFormats == displayFormats } @@ -147,11 +150,11 @@ extension MainContentCoordinator { guard tab.valueFilter.isActive else { return nil } let session = tabSessionRegistry.session(for: tabId) - let dataRevision = session?.dataRevision ?? 0 + let rowSetRevision = session?.rowSetRevision ?? 0 let formats = displayFormats(for: tab) if let cached = displayOrderCache[tabId], - cached.matches(dataRevision: dataRevision, valueFilter: tab.valueFilter, displayFormats: formats) { + cached.matches(rowSetRevision: rowSetRevision, valueFilter: tab.valueFilter, displayFormats: formats) { return cached.displayIDs } @@ -162,7 +165,7 @@ extension MainContentCoordinator { databaseType: connection.type ) displayOrderCache[tabId] = DisplayOrderCacheEntry( - dataRevision: dataRevision, + rowSetRevision: rowSetRevision, valueFilter: tab.valueFilter, displayFormats: formats, displayIDs: resolved @@ -170,9 +173,23 @@ extension MainContentCoordinator { return resolved } + /// Resolves the order now, against the rows as they stand, and stores it for every later reader. + /// + /// The order is a function of the values the rows held when the filter or the row set last + /// changed, not of the values they hold when someone asks. Resolving it lazily makes the answer + /// depend on whether anything happened to read it before the edit: the first read after an edit + /// that takes a row out of the filter's match drops that row, while the grid goes on showing it, + /// and every display position from there on names a different row than the grid has. + @discardableResult + func refreshDisplayOrder(forTab tabId: UUID) -> [RowID]? { + displayOrderCache.removeValue(forKey: tabId) + return displayIDs(forTab: tabId) + } + func setValueFilter(_ valueFilter: GridValueFilterState, forTab tabId: UUID) { guard tabManager.tabs.first(where: { $0.id == tabId })?.valueFilter != valueFilter else { return } tabManager.mutate(tabId: tabId) { $0.valueFilter = valueFilter } + refreshDisplayOrder(forTab: tabId) } /// Drops a value filter whose rows have been replaced wholesale. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+HighlightRules.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+HighlightRules.swift new file mode 100644 index 0000000000..ab684bfd84 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+HighlightRules.swift @@ -0,0 +1,66 @@ +// +// MainContentCoordinator+HighlightRules.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + func highlightRuleScope(for tab: QueryTab) -> TableScope? { + tab.tableContext.scope(connectionId: connectionId) + } + + func highlightRules(for tab: QueryTab) -> [HighlightRule] { + guard let scope = highlightRuleScope(for: tab) else { return tab.sessionHighlightRules } + return HighlightRuleStorage.shared.rules(for: scope) + } + + func setHighlightRules(_ rules: [HighlightRule], forTab tabId: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + if let scope = highlightRuleScope(for: tabManager.tabs[index]) { + HighlightRuleStorage.shared.setRules(rules, for: scope) + return + } + guard tabManager.tabs[index].sessionHighlightRules != rules else { return } + tabManager.mutate(at: index) { $0.sessionHighlightRules = rules } + } + + func applyQuickHighlight(_ rule: HighlightRule, forTab tabId: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + var rules = highlightRules(for: tab) + rules.removeAll { $0.hasSameCondition(as: rule) } + rules.insert(rule, at: 0) + setHighlightRules(rules, forTab: tabId) + } + + func removeHighlightRules(sharingConditionWith rule: HighlightRule, forTab tabId: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + let rules = highlightRules(for: tab).filter { !$0.hasSameCondition(as: rule) } + setHighlightRules(rules, forTab: tabId) + } + + func discardIncompleteHighlightRules(forTab tabId: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + let rules = highlightRules(for: tab) + let complete = rules.filter(\.isValid) + guard complete.count != rules.count else { return } + setHighlightRules(complete, forTab: tabId) + } + + func presentHighlightRules(addingRuleForColumn columnName: String? = nil, occurrence: Int = 0) { + guard let index = tabManager.selectedTabIndex else { return } + let tabId = tabManager.tabs[index].id + if let columnName { + let newRule = HighlightRule(columnName: columnName, columnOccurrence: occurrence) + setHighlightRules(highlightRules(for: tabManager.tabs[index]) + [newRule], forTab: tabId) + } + tabManager.mutate(at: index) { $0.display.highlightRulesPresentationRequest &+= 1 } + } + + var canPresentHighlightRules: Bool { + guard hasMountedDataGrid, + let tab = tabManager.selectedTab, + tab.display.resultsViewMode == .data else { return false } + return !(tabSessionRegistry.existingTableRows(for: tab.id)?.columns.isEmpty ?? true) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 34ab2f9f3b..6be29f998e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -34,6 +34,7 @@ extension MainContentCoordinator { schema: schema ?? table.schema, showStructure: showStructure, isView: !table.type.allowsRowEditing, + objectType: table.type, forceNonPreview: forceNonPreview, activateGridFocus: activateGridFocus, forceNewTab: forceNewTab @@ -46,6 +47,7 @@ extension MainContentCoordinator { schema: String? = nil, showStructure: Bool = false, isView: Bool = false, + objectType: TableInfo.TableType? = nil, forceNonPreview: Bool = false, activateGridFocus: Bool = false, forceNewTab: Bool = false @@ -97,6 +99,7 @@ extension MainContentCoordinator { currentDatabase: currentDatabase, resolvedSchema: resolvedSchema, isView: isView, + objectType: objectType, createAsPreview: createAsPreview, isInPlace: navigationModel == .inPlace ) @@ -150,6 +153,7 @@ extension MainContentCoordinator { currentDatabase: currentDatabase, resolvedSchema: resolvedSchema, isView: isView, + objectType: objectType, showStructure: showStructure, createAsPreview: createAsPreview ) @@ -168,6 +172,7 @@ extension MainContentCoordinator { databaseName: currentDatabase, schemaName: resolvedSchema, isView: isView, + objectType: objectType, showStructure: showStructure, isPreview: createAsPreview, forcesNewTab: forceNewTab @@ -233,6 +238,7 @@ extension MainContentCoordinator { currentDatabase: String, resolvedSchema: String?, isView: Bool, + objectType: TableInfo.TableType?, createAsPreview: Bool, isInPlace: Bool ) -> Bool { @@ -243,6 +249,7 @@ extension MainContentCoordinator { databaseName: currentDatabase, schemaName: resolvedSchema, isView: isView, + objectType: objectType, isPreview: createAsPreview ) } catch { @@ -260,6 +267,7 @@ extension MainContentCoordinator { TableLoadTracer.shared.stage(.addFirstTab, token: token) tabManager.mutate(at: tabIndex) { tab in tab.tableContext.isView = isView + tab.tableContext.objectType = objectType tab.tableContext.isEditable = !isView tab.tableContext.schemaName = resolvedSchema tab.pagination.reset() @@ -281,6 +289,7 @@ extension MainContentCoordinator { currentDatabase: String, resolvedSchema: String?, isView: Bool, + objectType: TableInfo.TableType?, showStructure: Bool, createAsPreview: Bool ) -> Bool { @@ -316,6 +325,7 @@ extension MainContentCoordinator { tableName: tableName, databaseType: connection.type, isView: isView, + objectType: objectType, databaseName: currentDatabase, schemaName: resolvedSchema, isPreview: createAsPreview diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift index 56728952d5..aee86b9c0f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift @@ -66,6 +66,7 @@ extension MainContentCoordinator { databaseName: tab.tableContext.databaseName, schemaName: tab.tableContext.schemaName, isView: tab.tableContext.isView, + objectType: tab.tableContext.objectType, resultsViewMode: tab.display.resultsViewMode, filterState: tab.filterState, sortColumns: tab.sortState.persistedColumns, @@ -102,15 +103,12 @@ extension MainContentCoordinator { let tableRows = tabSessionRegistry.tableRows(for: tab.id) guard let row = gridCoordinator.displayRow(at: displayIndex, in: tableRows) else { return nil } - let rowIndex = gridCoordinator.tableRowsIndex(forDisplayRow: displayIndex) - return NavigationRowAnchor.build( keyColumns: keyColumns, columns: tableRows.columns, values: row.values, isModified: { column in - guard let rowIndex else { return false } - return changeManager.isCellModified(rowIndex: rowIndex, columnIndex: column) + changeManager.isCellModified(rowID: row.id, columnIndex: column) } ) } @@ -173,6 +171,7 @@ extension MainContentCoordinator { tableName: entry.tableName, databaseType: connection.type, isView: entry.isView, + objectType: entry.objectType, databaseName: entry.databaseName, schemaName: entry.schemaName ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift index 3bcfabbfb6..34f5e8d13d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift @@ -83,21 +83,12 @@ extension MainContentCoordinator { database: String, schema: String? ) { - FilterSettingsStorage.shared.renameLastFilters( - from: oldName, - to: newName, - connectionId: connectionId, - databaseName: database, - schemaName: schema - ) - FileColumnLayoutPersister.shared.rename( - from: ColumnLayoutTableKey( - connectionId: connectionId, databaseName: database, schemaName: schema, tableName: oldName - ), - to: ColumnLayoutTableKey( - connectionId: connectionId, databaseName: database, schemaName: schema, tableName: newName - ) - ) + let scopedDatabase = database.isEmpty ? nil : database + let oldScope = TableScope(connectionId: connectionId, database: scopedDatabase, schema: schema, table: oldName) + let newScope = TableScope(connectionId: connectionId, database: scopedDatabase, schema: schema, table: newName) + for store in TableScopedSettingsRegistry.stores { + store.renameTable(from: oldScope, to: newScope) + } } private func moveFavorite(_ ref: DatabaseTreeTableRef, to newName: String, database: String?) { @@ -131,14 +122,12 @@ extension MainContentCoordinator { retargetPendingOperations( database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema ) - FilterSettingsStorage.shared.renameScope( - connectionId: connectionId, fromDatabase: database, fromSchema: schema, - toDatabase: toDatabase, toSchema: toSchema - ) - FileColumnLayoutPersister.shared.renameScope( - connectionId: connectionId, fromDatabase: database, fromSchema: schema, - toDatabase: toDatabase, toSchema: toSchema - ) + for store in TableScopedSettingsRegistry.stores { + store.renameContainer( + connectionId: connectionId, fromDatabase: database, fromSchema: schema, + toDatabase: toDatabase, toSchema: toSchema + ) + } retargetFavoriteTables( database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 7dcbc5619a..6476a3f396 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -38,10 +38,6 @@ extension MainContentCoordinator { rowEditingCoordinator.duplicateSelectedRow(index: index) } - func undoInsertRow(at rowIndex: Int) { - rowEditingCoordinator.undoInsertRow(at: rowIndex) - } - func handleUndoResult(_ result: UndoResult) { rowEditingCoordinator.handleUndoResult(result) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 1c080ca685..113a4a4df8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -134,35 +134,49 @@ extension MainContentCoordinator { WindowManager.shared.openTab(payload: payload) } - func editViewDefinition(_ viewName: String) { + /// Reads the view the row names, in the database and schema the row names, and opens the query + /// tab there. It used to read through the browse scope with the name alone, so a view selected + /// in another schema opened the definition of a same-named view in the browsed one, and running + /// it replaced that other view. + func editViewDefinition(_ ref: DatabaseTreeTableRef) { + guard let target = objectTarget(for: ref) else { return } + let viewName = ref.table.name Task { + let query: String do { - let definition = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: self.connection.id) { driver in + query = try await DatabaseManager.shared.withMetadataDriver(scope: target.scope) { driver in try await driver.fetchViewDefinition(view: viewName) } - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: definition - ) - WindowManager.shared.openTab(payload: payload) } catch { - let driver = DatabaseManager.shared.driver(for: self.connection.id) - let template = driver?.editViewFallbackTemplate(viewName: viewName) - ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" - let fallbackSQL = "-- Could not fetch view definition: \(error.localizedDescription)\n\(template)" - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: fallbackSQL + query = Self.viewDefinitionFallback( + viewName: viewName, + error: error, + driver: DatabaseManager.shared.driver(for: self.connection.id) ) - WindowManager.shared.openTab(payload: payload) } + WindowManager.shared.openTab(payload: EditorTabPayload( + connectionId: connection.id, + tabType: .query, + databaseName: target.scope.database, + schemaName: target.scope.schema, + initialQuery: query + )) } } + /// Every line of the error is commented out. A driver error can span several lines, and only the + /// first used to be, so the rest landed in the query tab as SQL. + static func viewDefinitionFallback(viewName: String, error: Error, driver: DatabaseDriver?) -> String { + let template = driver?.editViewFallbackTemplate(viewName: viewName) + ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" + let reason = error.localizedDescription + .split(separator: "\n", omittingEmptySubsequences: false) + .map { "-- \($0)" } + .joined(separator: "\n") + let heading = "-- " + String(localized: "Could not fetch the view definition:") + return "\(heading)\n\(reason)\n\(template)" + } + // MARK: - Export/Import /// The scope travels with the names because a bare name does not identify a table. Without it @@ -233,13 +247,13 @@ extension MainContentCoordinator { // MARK: - Maintenance - func supportedMaintenanceOperations() -> [String] { + func maintenanceOperations() -> [PluginMaintenanceOperation] { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return [] } - return driver.supportedMaintenanceOperations() ?? [] + return driver.maintenanceOperations() ?? [] } func showMaintenanceSheet( - operation: String, + operation: PluginMaintenanceOperation, tableName: String, database: String? = nil, schema: String? = nil @@ -249,6 +263,26 @@ extension MainContentCoordinator { ) } + /// The statements the confirmation sheet shows, built by the driver that will run them. + /// + /// Synchronous and pure, so the sheet can call it from `body` on every toggle. It used to write + /// its own SQL instead, which disagreed with what ran: `REINDEX orders`, not valid SQL, where + /// `REINDEX TABLE "orders"` runs. + func maintenancePreview( + operation: PluginMaintenanceOperation, + tableName: String?, + schema: String?, + options: [String: String] + ) -> [String] { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return [] } + return driver.maintenanceStatements( + operation: operation.name, + table: operation.target(tableName), + schema: schema, + options: options + ) ?? [] + } + /// Runs against the database the object it names lives in, on a scoped lease. /// /// A maintenance statement names its table and nothing else, so where it lands is decided @@ -259,16 +293,16 @@ extension MainContentCoordinator { /// Every other statement the user owns takes a scoped lease; this one now does too, which also /// puts it behind the same gate rather than interleaving with a tab's work on one handle. func executeMaintenance( - operation: String, + operation: PluginMaintenanceOperation, tableName: String, options: [String: String], database: String? = nil, schema: String? = nil ) { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - guard let statements = driver.maintenanceStatements( - operation: operation, table: tableName, options: options - ) else { return } + let statements = maintenancePreview( + operation: operation, tableName: tableName, schema: schema, options: options + ) + guard !statements.isEmpty else { return } /// The object the user picked names its own database, and only a command that names none /// falls back to where the browser is pointing. `resolvedScope` is what decides that, so a /// schema is never carried across a database boundary. @@ -276,6 +310,9 @@ extension MainContentCoordinator { database: database, schema: schema, for: connectionId ) ?? browseScope else { return } + /// What the statement acts on, which is the database itself for an operation that names no + /// object. Reporting the table there claimed work the statement never asked for. + let subject = operation.target(tableName) ?? scope.database Task { [weak self] in guard let self else { return } let decision = await ExecutionGateProvider.shared.authorize( @@ -286,13 +323,13 @@ extension MainContentCoordinator { kind: .maintenance, caller: .userInterface, capabilities: .interactiveUser, - operationDescription: operation + operationDescription: operation.name ) ) guard case .authorized = decision else { if let reason = decision.deniedReason { await AlertHelper.showErrorSheet( - title: String(format: String(localized: "%@ failed"), operation), + title: String(format: String(localized: "%@ failed"), operation.name), message: reason, window: self.contentWindow ) @@ -315,14 +352,18 @@ extension MainContentCoordinator { } } await AlertHelper.showInfoSheet( - title: String(format: String(localized: "%@ completed"), operation), + title: String(format: String(localized: "%@ completed"), operation.name), message: lastResult?.statusMessage - ?? String(format: String(localized: "%@ on %@ completed successfully."), operation, tableName), + ?? String( + format: String(localized: "%@ on %@ completed successfully."), + operation.name, + subject + ), window: self.contentWindow ) } catch { await AlertHelper.showErrorSheet( - title: String(format: String(localized: "%@ failed"), operation), + title: String(format: String(localized: "%@ failed"), operation.name), message: error.localizedDescription, window: self.contentWindow ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index c2d431a4dd..eafcfe3321 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -14,6 +14,16 @@ extension MainContentCoordinator { func saveSidebarEdits( editState: MultiRowEditState ) async throws { + let statements = try sidebarEditStatements(editedFields: editState.getEditedFields()) + guard !statements.isEmpty else { return } + try await executeSidebarChanges(statements: statements) + + runQuery() + } + + func sidebarEditStatements( + editedFields: [(columnIndex: Int, columnName: String, newValue: String?)] + ) throws -> [ParameterizedStatement] { guard let tab = tabManager.selectedTab, !selectionState.indices.isEmpty, tab.tableContext.tableName != nil, @@ -22,11 +32,10 @@ extension MainContentCoordinator { resultsViewMode: tab.display.resultsViewMode ) == .dataGrid else { - return + return [] } - let editedFields = editState.getEditedFields() - guard !editedFields.isEmpty else { return } + guard !editedFields.isEmpty else { return [] } let tableRows = tabSessionRegistry.tableRows(for: tab.id) let displayIDs = activeGridDisplayIDs @@ -38,7 +47,7 @@ extension MainContentCoordinator { ) else { return nil } let originalRow = Array(resolvedRow.values) return RowChange( - rowIndex: rowIndex, + rowID: resolvedRow.id, type: .update, cellChanges: editedFields.map { field in let oldValue: PluginCellValue = field.columnIndex < originalRow.count @@ -55,10 +64,6 @@ extension MainContentCoordinator { ) } - let statements = try changeManager.generateSQL(for: changes) - guard !statements.isEmpty else { return } - try await executeSidebarChanges(statements: statements) - - runQuery() + return try changeManager.generateSQL(for: changes) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index cc5092e718..96b2b8bc80 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -16,9 +16,9 @@ extension MainContentCoordinator { for tabId: UUID, _ mutate: (inout TableRows) -> Delta ) -> Delta { - var delta: Delta = .none - tabSessionRegistry.updateTableRows(for: tabId) { rows in - delta = mutate(&rows) + let delta = tabSessionRegistry.updateTableRows(for: tabId, mutate) + if delta.changesRowSet { + refreshDisplayOrder(forTab: tabId) } return delta } @@ -113,6 +113,7 @@ extension MainContentCoordinator { tab.tableContext.primaryKeyColumns = origin?.primaryKeyColumns ?? [] tab.tableContext.isEditable = origin?.isEditable ?? false tab.tableContext.isView = origin?.isView ?? false + tab.tableContext.objectType = origin?.objectType tab.schemaVersion += 1 } let tab = tabManager.tabs[tabIdx] diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 65b9fcda95..230e39edac 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -144,8 +144,14 @@ extension MainContentView { var isSelectedRowDeleted: Bool { guard gridSelectionOwner == .dataGrid, - let firstIndex = coordinator.selectionState.indices.min() else { return false } - return coordinator.changeManager.isRowDeleted(firstIndex) + let tabId = coordinator.tabManager.selectedTab?.id, + let firstIndex = coordinator.selectionState.indices.min(), + let row = DisplayRowMapping.row( + forDisplay: firstIndex, + displayIDs: coordinator.activeGridDisplayIDs, + in: coordinator.tabSessionRegistry.tableRows(for: tabId) + ) else { return false } + return coordinator.changeManager.isRowDeleted(row.id) } // MARK: - Sort State Binding diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 195e77f3cc..6f4be4606a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -169,12 +169,10 @@ extension MainContentView { let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) let displayIDs = coordinator.activeGridDisplayIDs - var allRows: [[PluginCellValue]] = [] - for displayIndex in selectedIndices.sorted() { - if let row = DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) { - allRows.append(Array(row.values)) - } + let selectedRows = selectedIndices.sorted().compactMap { + DisplayRowMapping.row(forDisplay: $0, displayIDs: displayIDs, in: tableRows) } + let allRows = selectedRows.map { Array($0.values) } var columnTypes = tableRows.columnTypes for (i, col) in tableRows.columns.enumerated() where i < columnTypes.count { @@ -193,8 +191,8 @@ extension MainContentView { } var modifiedColumns = Set() - for rowIndex in selectedIndices { - modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(rowIndex)) + for row in selectedRows { + modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(row.id)) } let pkColumns = Set(tab.tableContext.primaryKeyColumns) @@ -253,7 +251,7 @@ extension MainContentView { } capturedCoordinator.changeManager.recordCellChange( - rowIndex: rowIndex, + rowID: resolvedRow.id, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, diff --git a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift index 635524e5b0..83b8f6978c 100644 --- a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift +++ b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit /// Commands that act on the object selected in the sidebar. The sidebar's own /// context menu reaches the same coordinator methods, so the menu bar is a second @@ -25,19 +26,75 @@ extension MainContentCommandActions { } func editViewDefinition() { - guard let object = selectedObject, object.type == .view else { return } - coordinator?.editViewDefinition(object.name) + guard let ref = selectedObjectRef, ref.table.type == .view else { return } + coordinator?.editViewDefinition(ref) } - var maintenanceOperations: [String] { - guard selectedObject != nil else { return [] } - return coordinator?.supportedMaintenanceOperations() ?? [] + var canShowObjectDDL: Bool { + DatabaseObjectToolEligibility.canShowDDL(selectedObject?.type) + } + + func showObjectDDL() { + guard let ref = selectedObjectRef, + let objectRef = DatabaseObjectRef( + relation: ref.table, + database: ref.database ?? "", + schema: ref.qualifyingSchema + ) + else { return } + coordinator?.showObjectSource(objectRef) + } + + func copyObjectDDL() { + guard let ref = selectedObjectRef, canShowObjectDDL else { return } + coordinator?.copyDDL(of: ref) + } + + var canRefreshMaterializedView: Bool { + DatabaseObjectToolEligibility.canRefresh( + selectedObject?.type, + support: objectToolSupport, + isReadOnly: isReadOnly + ) + } + + func refreshMaterializedView() { + guard let ref = selectedObjectRef, canRefreshMaterializedView else { return } + coordinator?.refreshMaterializedView(ref) + } + + var canEditObjectComment: Bool { + DatabaseObjectToolEligibility.canEditComment( + selectedObject?.type, + support: objectToolSupport, + isReadOnly: isReadOnly + ) + } + + func editObjectComment() { + guard let ref = selectedObjectRef, canEditObjectComment else { return } + coordinator?.editComment(of: ref) + } + + private var objectToolSupport: DatabaseObjectToolEligibility.Support { + guard let connectionId = coordinator?.connectionId else { return .none } + return .of(DatabaseManager.shared.driver(for: connectionId)) + } + + /// Narrowed to the selected object's kind, the same rule the sidebar's submenu follows. Asking + /// only whether something was selected is how the menu bar offered `REINDEX` on a view. + var maintenanceOperations: [PluginMaintenanceOperation] { + guard let object = selectedObject else { return [] } + return TableOperationEligibility.maintenanceOperations( + coordinator?.maintenanceOperations() ?? [], + for: object.type + ) } /// The menu acts on the object browser's selection, and `TableInfo` carries a schema but no /// database, so this names only the schema and the command falls back to the database being /// browsed. The sidebar's own contextual menu carries the clicked row's database and does not. - func runMaintenanceOperation(_ operation: String) { + func runMaintenanceOperation(_ operation: PluginMaintenanceOperation) { guard let object = selectedObject else { return } coordinator?.showMaintenanceSheet( operation: operation, tableName: object.name, schema: object.schema diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 8ff2fda18c..d338bc898c 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -501,12 +501,20 @@ final class MainContentCommandActions { TableOperationEligibility.canTruncate(selectedTables.wrappedValue) } + /// The one selected object with the database and schema it lives in, or nil when the selection + /// is empty or spans several. A command that acts on the object takes this rather than the bare + /// `TableInfo`, which names no database, so it reaches the object the user selected even while + /// the browser points at another database or schema. + var selectedObjectRef: DatabaseTreeTableRef? { + let selection = selectedTables.wrappedValue + guard selection.count == 1 else { return nil } + return selection.first + } + /// The one selected object, or nil when the selection is empty or spans several. /// Commands that open a single object need this rather than `hasTableSelection`. var selectedObject: TableInfo? { - let selection = selectedTables.wrappedValue - guard selection.count == 1 else { return nil } - return selection.first?.table + selectedObjectRef?.table } var hasQueryText: Bool { @@ -936,6 +944,15 @@ final class MainContentCommandActions { coordinator.toggleFilterPanel() } + var canPresentHighlightRules: Bool { + coordinator?.canPresentHighlightRules ?? false + } + + func showHighlightRules() { + guard canPresentHighlightRules, let coordinator else { return } + coordinator.presentHighlightRules() + } + func showFindBar() { guard canUseGridFindCommands, let coordinator else { return } coordinator.findCoordinator.show() @@ -1456,6 +1473,19 @@ final class MainContentCommandActions { } } .store(in: &eventCancellables) + + AppCommands.shared.objectChanged + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self, change.connectionId == self.connection.id, + let coordinator = self.coordinator else { return } + coordinator.applyObjectChange( + change, + hasPendingTableOps: self.hasPendingTableOps, + onDiscard: { [weak self] in self?.clearPendingTableOps() } + ) + } + .store(in: &eventCancellables) } // MARK: Database Broadcasts diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 4d5cf1eb98..348b5eacd1 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -68,8 +68,17 @@ enum ActiveSheet: Identifiable { /// its table and nothing else, so acting on wherever the object browser happens to point /// maintains the same-named table in another database whenever the two have drifted apart. /// This is the rule the sidebar's other destructive commands already keep by carrying their ref. - case maintenance(operation: String, tableName: String, database: String?, schema: String?) + case maintenance( + operation: PluginMaintenanceOperation, + tableName: String, + database: String?, + schema: String? + ) case createDatabase + /// The object's own database and schema travel in the target, for the reason `.maintenance` + /// carries them: the comment is written to the object the user right-clicked, not to a + /// same-named one wherever the browser points by the time Save is pressed. + case editObjectComment(DatabaseObjectTarget) /// Copying carries the whole launch request, because the source database, the source schema /// and the objects the user right-clicked are all part of what the sheet opens onto, and the /// object browser may be pointed somewhere else by the time the sheet appears. @@ -90,8 +99,10 @@ enum ActiveSheet: Identifiable { case .restoreDatabase(let fileURL): "restoreDatabase-\(fileURL.path)" case .serverSideExport(let table): "serverSideExport-\(table ?? "")" case .maintenance(let operation, let tableName, let database, let schema): - "maintenance-\(operation)-\(database ?? "")-\(schema ?? "")-\(tableName)" + "maintenance-\(operation.name)-\(database ?? "")-\(schema ?? "")-\(tableName)" case .createDatabase: "createDatabase" + case .editObjectComment(let target): + "editObjectComment-\(target.scope.database)-\(target.scope.schema ?? "")-\(target.name)" case .copyObjects(let launch): "copyObjects-\(launch.id)" case .rewind: "rewind" case .tableRebuildReview: "tableRebuildReview" diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 9607edd0f9..cf20d267d0 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -205,6 +205,8 @@ struct MainContentView: View { ) case .copyObjects(let launch): CopyObjectsSheet(launch: launch, connection: connection) + case .editObjectComment(let target): + ObjectCommentSheet(target: target, connection: connection) case .exportDialog, .exportQueryResults, .importDialog, .rowImport, .transferTables, .backupDatabase, .restoreDatabase, .serverSideExport: transferSheetContent(for: sheet, dismiss: dismissBinding) @@ -212,8 +214,16 @@ struct MainContentView: View { MaintenanceSheet( operation: operation, tableName: tableName, - databaseType: connection.type, - onExecute: { operation, tableName, options in + databaseName: database ?? coordinator.browseDatabaseName, + preview: { options in + coordinator.maintenancePreview( + operation: operation, + tableName: tableName, + schema: schema, + options: options + ) + }, + onExecute: { options in coordinator.executeMaintenance( operation: operation, tableName: tableName, @@ -405,9 +415,6 @@ struct MainContentView: View { onAddRow: { coordinator.addNewRow() }, - onUndoInsert: { rowIndex in - coordinator.undoInsertRow(at: rowIndex) - }, onSelectionChange: { newIndices in /// Any grid selection counts, not just a table tab's. The setting is called /// "Auto-show inspector on row select" and both docs pages describe it that way, diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift index fc550814a0..3b7d855957 100644 --- a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -2,7 +2,7 @@ // ObjectSourceTabView.swift // TablePro // -// Tab showing the source of one stored procedure, function, trigger or user-defined type. +// Tab showing the source of one stored procedure, function, trigger, user-defined type or view. // import SwiftUI @@ -107,6 +107,13 @@ final class ObjectSourceLoader { enumLabels: fetched.enumLabels, userType: fetched ) + case .view, .materializedView: + let source = try await TableDDLComposer.fetchDDL( + for: objectRef.name, + using: driver, + includesDependencies: true + ) + return Fetched(source: source, attributes: objectRef.attributes, enumLabels: [], userType: nil) } } } diff --git a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift index 8470242aa3..8252d6689e 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift @@ -53,7 +53,7 @@ struct QueryInsightsActivityChart: View { } .chartForegroundStyleScale([ Outcome.succeeded: Color.accentColor, - Outcome.failed: Color.orange, + Outcome.failed: ThemeEngine.shared.palette.color(.statusWarning), ]) .chartLegend(position: .top, alignment: .trailing, spacing: 8) .chartYAxis { diff --git a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift index 8cdd2d1e60..4f3e392275 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift @@ -48,7 +48,7 @@ struct QueryInsightsGroupList: View { if let error = group.latestErrorMessage, case .failures = metric { RevealedTextView(error) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .lineLimit(2) } } @@ -86,7 +86,7 @@ struct QueryInsightsGroupList: View { private func headlineTint(_ group: QueryInsightsGroup) -> Color { switch metric { - case .failures: return .orange + case .failures: return ThemeEngine.shared.palette.color(.statusWarning) case .duration: return .primary case .callCount: return .primary } @@ -160,7 +160,7 @@ struct QueryInsightsRegressionList: View { .font(.system(.callout, design: .monospaced)) .fontWeight(.medium) .monospacedDigit() - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .frame(minWidth: 76, alignment: .trailing) VStack(alignment: .leading, spacing: 3) { diff --git a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift index 24f099283c..c7d09b1c09 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift @@ -30,7 +30,7 @@ struct QueryInsightsSummaryBar: View { totals.totalCount.formatted() ), systemImage: "exclamationmark.triangle", - tint: totals.failedCount > 0 ? .orange : .secondary + tint: totals.failedCount > 0 ? ThemeEngine.shared.palette.color(.statusWarning) : .secondary ) metric( label: String(localized: "Average"), diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index c720bcff49..03d3df7a12 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -162,7 +162,7 @@ struct ArrayValueEditorView: View { .pickerStyle(.menu) if ArrayValueEditorModel.isDriftedValue(row.element, allowedValues: allowedValues) { Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .help(Text("This value is not one of the type's current labels")) } } diff --git a/TablePro/Views/Results/CellOverlayBase.swift b/TablePro/Views/Results/CellOverlayBase.swift index ff4bd8c92a..2498f26283 100644 --- a/TablePro/Views/Results/CellOverlayBase.swift +++ b/TablePro/Views/Results/CellOverlayBase.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine enum CellOverlayDismissReason { case userAction @@ -233,16 +234,27 @@ class CellOverlayBase: NSObject { final class CellOverlayContainerView: NSView { override var isFlipped: Bool { true } + private var themeCancellable: AnyCancellable? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil, themeCancellable == nil else { return } + + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.applyLayerColors() } + } + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() applyLayerColors() } /// A `CGColor` is a resolved colour and a layer never resolves it again, so the two layer - /// colours are reapplied whenever the appearance changes under an open overlay. + /// colours are reapplied whenever the appearance or the theme changes under an open overlay. func applyLayerColors() { effectiveAppearance.performAsCurrentDrawingAppearance { - layer?.borderColor = NSColor.keyboardFocusIndicatorColor.cgColor + layer?.borderColor = ThemeEngine.shared.palette[.gridFocusBorder].cgColor layer?.backgroundColor = NSColor.textBackgroundColor.cgColor } } diff --git a/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift b/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift index 3978dc9a0c..b8c2bed0e1 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift @@ -87,13 +87,32 @@ internal final class DataGridCellAccessibilityView: NSView { override internal func accessibilityValue() -> Any? { text } + /// Every combination is its own format string rather than pieces joined at run time, so a + /// translator sees a whole sentence and can order its parts. The pending change comes before the + /// highlight rule, because it is the one the reader is about to save. override internal func accessibilityLabel() -> String? { - String( - format: String(localized: "Row %d, column %d: %@"), - row + 1, - dataColumn + 1, - text - ) + let pendingChange = coordinator?.pendingChangeDescription(row: row, columnIndex: dataColumn) + let highlight = coordinator?.highlightDescription(row: row, columnIndex: dataColumn) + + if let pendingChange, let highlight { + return String( + format: String(localized: "Row %d, column %d: %@, %@, highlighted where %@"), + row + 1, dataColumn + 1, text, pendingChange, highlight + ) + } + if let pendingChange { + return String( + format: String(localized: "Row %d, column %d: %@, %@"), + row + 1, dataColumn + 1, text, pendingChange + ) + } + if let highlight { + return String( + format: String(localized: "Row %d, column %d: %@, highlighted where %@"), + row + 1, dataColumn + 1, text, highlight + ) + } + return String(format: String(localized: "Row %d, column %d: %@"), row + 1, dataColumn + 1, text) } /// Read through rather than stored, so an edit, an undo or a display-format change is spoken diff --git a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift index 9a0d23f2d9..f1417c657f 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift @@ -15,8 +15,11 @@ struct DataGridCellAppearance: Equatable { let text: String let font: NSFont let textColor: NSColor - /// Painted behind the text, for a find match or a modified value. + /// Painted behind the text, for a find match, a modified value or a highlight rule. let backgroundTint: NSColor? + /// The line a pending change draws through or under the text. Unlike the tint, it survives a + /// selection, which is what makes it the cue rather than a decoration on one. + let textMark: DataGridCellTextMark? let accessory: DataGridCellAccessory /// Which symbol the accessory draws, resolved here because it follows the row's state rather /// than anything the renderer can see. @@ -42,28 +45,29 @@ struct DataGridCellAppearance: Equatable { switch content.placeholder { case .none: font = palette.regularFont - baseColor = deletedTextColor ?? .labelColor + baseColor = deletedTextColor ?? valueTextColor(kind: kind, rawValue: content.rawValue, palette: palette) case .null, .empty: font = palette.italicFont - baseColor = deletedTextColor ?? .secondaryLabelColor + baseColor = deletedTextColor ?? palette.placeholderText case .defaultMarker: font = palette.mediumFont baseColor = deletedTextColor ?? .systemBlue } let findTint: NSColor? = state.isCurrentFindMatch ? palette.findMatchTint : nil - let modifiedTint: NSColor? + let highlightColor = state.visualState.cellHighlightColor(forColumn: state.columnIndex) + let stateTint: NSColor? if state.visualState.isDeleted || state.visualState.isInserted { - modifiedTint = nil + stateTint = nil } else if state.visualState.isModified(columnIndex: state.columnIndex) { - modifiedTint = palette.modifiedColumnTint + stateTint = palette.modifiedColumnTint } else { - modifiedTint = nil + stateTint = highlightColor?.washColor } // A find match keeps its own highlight whatever else is true, and the text turns black // against it. Otherwise a selected row's text takes the selection's own colour, and the - // modified tint stands down so the selection fill is not painted over. + // modified or highlight tint stands down so the selection fill is not painted over. let backgroundTint: NSColor? let textColor: NSColor if let findTint { @@ -73,7 +77,7 @@ struct DataGridCellAppearance: Equatable { backgroundTint = nil textColor = .alternateSelectedControlTextColor } else { - backgroundTint = modifiedTint + backgroundTint = stateTint textColor = baseColor } @@ -93,6 +97,7 @@ struct DataGridCellAppearance: Equatable { font: font, textColor: textColor, backgroundTint: backgroundTint, + textMark: DataGridCellTextMark.resolve(state: state.visualState, columnIndex: state.columnIndex), accessory: accessory, accessoryRole: DataGridCellAccessoryGlyph.Role( accessory: accessory, @@ -103,4 +108,15 @@ struct DataGridCellAppearance: Equatable { drawsFocusRing: isCursorVisible && !onEmphasizedSelection ) } + + private static func valueTextColor( + kind: DataGridCellKind, + rawValue: String?, + palette: DataGridCellPalette + ) -> NSColor { + guard kind == .boolean, let rawValue, let isTrue = StoredBoolean.value(of: rawValue) else { + return palette.text + } + return isTrue ? palette.booleanTrueText : palette.booleanFalseText + } } diff --git a/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/TablePro/Views/Results/Cells/DataGridCellPalette.swift index a0f56df65b..ee04d69fc7 100644 --- a/TablePro/Views/Results/Cells/DataGridCellPalette.swift +++ b/TablePro/Views/Results/Cells/DataGridCellPalette.swift @@ -10,6 +10,11 @@ struct DataGridCellPalette: Equatable { let regularFont: NSFont let italicFont: NSFont let mediumFont: NSFont + let text: NSColor + let placeholderText: NSColor + let booleanTrueText: NSColor + let booleanFalseText: NSColor + let rowNumberText: NSColor let deletedRowText: NSColor let modifiedColumnTint: NSColor let findMatchTint: NSColor @@ -18,6 +23,11 @@ struct DataGridCellPalette: Equatable { regularFont: .systemFont(ofSize: NSFont.systemFontSize), italicFont: .systemFont(ofSize: NSFont.systemFontSize), mediumFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium), + text: .labelColor, + placeholderText: .secondaryLabelColor, + booleanTrueText: .labelColor, + booleanFalseText: .labelColor, + rowNumberText: .secondaryLabelColor, deletedRowText: .secondaryLabelColor, modifiedColumnTint: .systemYellow, findMatchTint: .findHighlightColor @@ -30,8 +40,13 @@ extension ThemeEngine { regularFont: dataGridFonts.regular, italicFont: dataGridFonts.italic, mediumFont: dataGridFonts.medium, - deletedRowText: colors.dataGrid.deletedText, - modifiedColumnTint: colors.dataGrid.modified, + text: resolved[.gridText], + placeholderText: resolved[.gridNullValue], + booleanTrueText: resolved[.gridBoolTrue], + booleanFalseText: resolved[.gridBoolFalse], + rowNumberText: resolved[.gridRowNumber], + deletedRowText: resolved[.gridDeletedText], + modifiedColumnTint: resolved[.gridModified], findMatchTint: .findHighlightColor ) } diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index f7bb521a1f..781084b970 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -57,7 +57,6 @@ final class DataGridCellRegistry { cell.alignment = .right cell.font = ThemeEngine.shared.dataGridFonts.rowNumber cell.tag = DataGridFontVariant.rowNumber - cell.textColor = .secondaryLabelColor cell.translatesAutoresizingMaskIntoConstraints = false cellView.textField = cell @@ -76,6 +75,7 @@ final class DataGridCellRegistry { ]) } + cell.textColor = rowNumberColor(for: visualState) guard row >= 0 && row < cachedRowCount else { cell.stringValue = "" return cellView @@ -83,10 +83,13 @@ final class DataGridCellRegistry { let displayNumber = row + pageOffset + 1 cell.stringValue = "\(displayNumber)" - cell.textColor = visualState.isDeleted ? ThemeEngine.shared.colors.dataGrid.deletedText : .secondaryLabelColor cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), displayNumber)) cellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1)) return cellView } + + func rowNumberColor(for visualState: RowVisualState) -> NSColor { + visualState.isDeleted ? palette.deletedRowText : palette.rowNumberText + } } diff --git a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift index d17d8f47f4..42ce84352a 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift @@ -20,6 +20,7 @@ final class DataGridCellRenderer { let text: String let font: NSFont let color: NSColor + let mark: DataGridCellTextMark? } /// A viewport holds a few hundred cells and a scroll reuses them, so this only has to outlive a @@ -64,9 +65,9 @@ final class DataGridCellRenderer { let availableWidth = appearance.accessory.availableTextWidth(in: rect) guard availableWidth > 0, let context = NSGraphicsContext.current?.cgContext else { return } - let fullLine = line(for: appearance.text, font: appearance.font, color: appearance.textColor) + let fullLine = line(for: appearance.text, appearance: appearance) let typographicWidth = CTLineGetTypographicBounds(fullLine, nil, nil, nil) - let ellipsis = line(for: "\u{2026}", font: appearance.font, color: appearance.textColor) + let ellipsis = line(for: "\u{2026}", appearance: appearance) let ellipsisWidth = CTLineGetTypographicBounds(ellipsis, nil, nil, nil) guard Double(availableWidth) >= ellipsisWidth else { return } @@ -87,18 +88,22 @@ final class DataGridCellRenderer { context.restoreGState() } - private func line(for text: String, font: NSFont, color: NSColor) -> CTLine { - let key = LineKey(text: text, font: font, color: color) + /// Built with the mark's own attribute so `CTLineDraw` draws the line from the font's metrics, + /// and so `CTLineCreateTruncatedLine` carries it onto a truncated line and its ellipsis. The + /// mark is part of the key because two cells holding the same value in the same font and colour + /// are two different drawings once one of them is struck through. + private func line(for text: String, appearance: DataGridCellAppearance) -> CTLine { + let font = appearance.font + let key = LineKey(text: text, font: font, color: appearance.textColor, mark: appearance.textMark) if let cached = lineCache[key] { return cached } let source = text as NSString let laidOut = source.length > Self.maximumLaidOutCharacters ? source.substring(to: Self.maximumLaidOutCharacters) + "\u{2026}" : text - let attributed = NSAttributedString( - string: laidOut, - attributes: [.font: font, .foregroundColor: color] - ) + var attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: appearance.textColor] + appearance.textMark?.attributes.forEach { attributes[$0.key] = $0.value } + let attributed = NSAttributedString(string: laidOut, attributes: attributes) let created = CTLineCreateWithAttributedString(attributed as CFAttributedString) if lineCache.count >= Self.lineCacheLimit { @@ -133,7 +138,7 @@ final class DataGridCellRenderer { private func drawFocusRing(in rect: NSRect) { let path = NSBezierPath(rect: rect.insetBy(dx: 1, dy: 1)) path.lineWidth = 2 - NSColor.keyboardFocusIndicatorColor.setStroke() + ThemeEngine.shared.palette[.gridFocusBorder].setStroke() path.stroke() } } diff --git a/TablePro/Views/Results/Cells/DataGridCellTextMark.swift b/TablePro/Views/Results/Cells/DataGridCellTextMark.swift new file mode 100644 index 0000000000..9a4502b17a --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridCellTextMark.swift @@ -0,0 +1,55 @@ +// +// DataGridCellTextMark.swift +// TablePro +// + +import AppKit + +/// The line a pending change draws through or under a cell's own text, the way Pages marks tracked +/// changes. +/// +/// The tints alone could not carry this. A selected row paints over them, so a reader who selects +/// the rows they are about to save loses every mark at once, and a tint says nothing to a reader +/// who cannot see colour or is using a client that reads the grid rather than looking at it. The +/// line is drawn in the text's own colour, so it survives selection, Dark Mode, grayscale and +/// Increase Contrast, and the tints stay as the second cue. +enum DataGridCellTextMark: Equatable { + /// A row staged for deletion, struck through as Pages strikes deleted text. + case struckThrough + /// A new row, or an edited cell, underlined as Pages underlines inserted text. + case underlined + + /// What a pending change makes of a cell, or nil when nothing is staged for it. + /// + /// A staged delete outranks the rest: the row is going away whatever was typed into it first. + static func resolve(state: RowVisualState, columnIndex: Int) -> DataGridCellTextMark? { + if state.isDeleted { return .struckThrough } + if state.isInserted { return .underlined } + return state.isModified(columnIndex: columnIndex) ? .underlined : nil + } + + /// Read by `CTLineDraw` itself, which draws both lines from the font's own metrics. Measured on + /// macOS 27: a `CTLine` built from an attributed string carrying `.underlineStyle` or + /// `.strikethroughStyle` draws that line, in the run's foreground colour, and + /// `CTLineCreateTruncatedLine` carries it onto the truncated line. So the renderer never + /// computes a line's position, thickness or width, and a cell that truncates is marked to + /// exactly where its text ends. + var attributes: [NSAttributedString.Key: Any] { + switch self { + case .struckThrough: + return [.strikethroughStyle: NSUnderlineStyle.single.rawValue] + case .underlined: + return [.underlineStyle: NSUnderlineStyle.single.rawValue] + } + } + + /// What a client reading the grid is told, since a line it cannot see is no cue at all. + /// + /// Resolved from the state rather than from the mark, because one line covers two changes a + /// reader needs told apart: a whole row that is new, and one edited value in a row that is not. + static func accessibilityDescription(state: RowVisualState, columnIndex: Int) -> String? { + if state.isDeleted { return String(localized: "marked for deletion") } + if state.isInserted { return String(localized: "new row") } + return state.isModified(columnIndex: columnIndex) ? String(localized: "edited") : nil + } +} diff --git a/TablePro/Views/Results/ColumnValueFilterPopover.swift b/TablePro/Views/Results/ColumnValueFilterPopover.swift index f08d51bf17..f6159949d5 100644 --- a/TablePro/Views/Results/ColumnValueFilterPopover.swift +++ b/TablePro/Views/Results/ColumnValueFilterPopover.swift @@ -85,6 +85,7 @@ struct ColumnValueFilterPopover: View { ) HStack(spacing: 6) { TristateCheckbox(state: selectAllState) { toggleSelectAll() } + .accessibilityLabel(String(localized: "Select All")) Text("Select All") Spacer() } @@ -101,7 +102,7 @@ struct ColumnValueFilterPopover: View { Text(label(for: value)) .lineLimit(1) .truncationMode(.tail) - .foregroundStyle(value.isNull ? Color.secondary : Color.primary) + .foregroundStyle(value.isNull ? ThemeEngine.shared.palette.color(.gridNullValue) : Color.primary) Spacer(minLength: 8) Text("\(value.count)") .font(.callout.monospacedDigit()) @@ -109,6 +110,7 @@ struct ColumnValueFilterPopover: View { } } .toggleStyle(.checkbox) + .accessibilityIdentifier("value-filter-value") } } .listStyle(.plain) diff --git a/TablePro/Views/Results/DataGridBodyChrome.swift b/TablePro/Views/Results/DataGridBodyChrome.swift index 4c40ee3f9b..2700998f82 100644 --- a/TablePro/Views/Results/DataGridBodyChrome.swift +++ b/TablePro/Views/Results/DataGridBodyChrome.swift @@ -67,7 +67,7 @@ enum DataGridBodyChrome { ) { let separators = separatorRects(in: rect, of: view, tableView: tableView, presentsColumn: presentsColumn) guard !separators.isEmpty else { return } - tableView.gridColor.setFill() + ThemeEngine.shared.palette[.gridLine].setFill() separators.forEach { $0.fill() } } @@ -114,13 +114,35 @@ enum DataGridBodyChrome { return bands } - /// The alternate stripe `NSTableRowView` paints for a row, or nil when the table does not - /// alternate, where a row shows the table's own background. + /// The theme's background, falling back to the system's, for the table to paint itself with. + /// + /// Set on the table rather than read from the theme at each draw, because AppKit reads the + /// table's own `backgroundColor` too, for the area an elastic scroll uncovers. + static func applyBackground(to tableView: NSTableView) { + let background = ThemeEngine.shared.palette[.gridBackground] + guard tableView.backgroundColor != background else { return } + tableView.backgroundColor = background + } + + /// The alternate stripe a row carries, or nil when the table does not alternate, where a row + /// shows the table's own background. + /// + /// The theme's pair when it declares one, otherwise `NSColor.alternatingContentBackgroundColors`, + /// which is the pair `NSTableView` itself hands its row views. static func stripeColor(forRow row: Int, of tableView: NSTableView) -> NSColor? { guard tableView.usesAlternatingRowBackgroundColors else { return nil } - let stripes = NSColor.alternatingContentBackgroundColors - guard !stripes.isEmpty else { return nil } - return stripes[row % stripes.count] + return row.isMultiple(of: 2) + ? ThemeEngine.shared.palette[.gridBackground] + : ThemeEngine.shared.palette[.gridAlternateRow] + } + + /// What a row view lays down before its tint and selection: its stripe, or the table's own + /// background when the table does not alternate. + /// + /// Painted by `DataGridRowView` itself rather than by `NSTableRowView`, which paints the system + /// stripes whatever the theme declares. + static func rowBackgroundColor(forRow row: Int, of tableView: NSTableView) -> NSColor { + stripeColor(forRow: row, of: tableView) ?? tableView.backgroundColor } /// Lays `background` down and blends `layers` over it bottom to top, which is the colour a row diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 88ca3cb1d4..08466bc598 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -33,11 +33,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData NSMenuDelegate { var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } - var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } + var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Delta) -> Delta = { _ in .none } var paginationOffsetProvider: @MainActor () -> Int = { 0 } var changeManager: AnyChangeManager var isEditable: Bool var editRefusalMessage: String? + /// Headings this grid must refuse an edit for while the rest of it stays editable. See + /// `DataGridConfiguration.lockedColumns`. + var lockedColumns: Set = [] var valueFilteredIDs: [RowID]? { didSet { bumpDisplayRevision() } } /// Ticks whenever the displayed row order or the value filter changes. /// @@ -49,6 +52,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData /// the filter lives here alone and dies with this coordinator, which is all a structure or /// create-table grid needs. var valueFilterBinding: Binding? + var displayOrderProvider: (@MainActor () -> [RowID]?)? private var storedValueFilterState = GridValueFilterState() /// Reads never go back through the binding, because a SwiftUI `Binding` built from a captured /// value type returns the pre-write value until the next body pass. The mirror is authoritative @@ -244,6 +248,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } self.isEditable = isEditable editRefusalMessage = configuration.editRefusalMessage + lockedColumns = configuration.lockedColumns tableView?.toolTip = isEditable ? nil : configuration.editRefusalMessage dropdownColumns = configuration.dropdownColumns typePickerColumns = configuration.typePickerColumns @@ -550,6 +555,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") let visualIndex = RowVisualIndex() + var highlightRuleSet: HighlightRuleSet = .empty private let largeDatasetThreshold = 5_000 var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } @@ -609,12 +615,18 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData themeCancellable = AppEvents.shared.themeChanged .receive(on: RunLoop.main) .sink { [weak self] _ in + if let tableView = self?.tableView { + DataGridBodyChrome.applyBackground(to: tableView) + tableView.headerView?.needsDisplay = true + tableView.cornerView?.needsDisplay = true + } self?.reloadVisibleRowsAndStates() /// The row-number font is a theme value and it decides the column's width, which /// the pinned gutter mirrors. Nothing re-measured it on a theme change before, so /// the width was already going stale here. self?.resizeRowNumberColumnForCurrentRange() self?.repaintRowGutter() + self?.selectionController.overlay?.needsDisplay = true } } @@ -693,6 +705,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData systemTimeZoneCancellable = nil detachAccessibilityActivationObserver() visualIndex.clear() + highlightRuleSet = .empty displayCache.removeAll() columnDisplayFormats = [] cachedRowCount = 0 @@ -702,6 +715,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // Local only: this runs while the window is being torn down, and the filter's owner is // either about to go away with it or is deliberately keeping the filter for the next mount. valueFilterBinding = nil + displayOrderProvider = nil adoptValueFilter(GridValueFilterState()) lastUpdateSnapshot = nil columnPool.detachFromTableView() @@ -744,9 +758,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData reloadAfterRowMutationWithValueFilter() return } - visualIndex.rebuild(from: changeManager, displayIDs: displayIDs) + visualIndex.rebuild(from: changeManager) updateCache() tableView.insertRows(at: indices, withAnimation: Self.rowAnimation(.slideDown)) + repaintVisibleRowDecorations() } /// Accessibility > Display > Reduce Motion asks for no sliding rows, and the app @@ -761,9 +776,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData reloadAfterRowMutationWithValueFilter() return } - visualIndex.rebuild(from: changeManager, displayIDs: displayIDs) + visualIndex.rebuild(from: changeManager) updateCache() tableView.removeRows(at: indices, withAnimation: Self.rowAnimation(.slideUp)) + repaintVisibleRowDecorations() } private func bumpDisplayRevision() { @@ -799,7 +815,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard let tableView else { return } recomputeValueFilteredIDs() updateCache() - visualIndex.rebuild(from: changeManager, displayIDs: displayIDs) + visualIndex.rebuild(from: changeManager) tableView.reloadData() startBackgroundPrewarm() } @@ -921,7 +937,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func invalidateAllDisplayCaches() { displayCache.removeAll() - visualIndex.rebuild(from: changeManager, displayIDs: displayIDs) + visualIndex.rebuild(from: changeManager) } @discardableResult @@ -1046,6 +1062,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) { guard let row = displayRow(at: displayIndex) else { return } + displayCache.clearHighlight(forID: row.id) guard let box = displayCache.box(forID: row.id), column >= 0, column < box.values.count else { return } box.values[column] = nil @@ -1060,8 +1077,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData else { return } guard row >= 0, row < tableView.numberOfRows else { return } invalidateDisplayCache(forDisplayRow: row, column: column) - visualIndex.updateRow(row, from: changeManager, displayIDs: displayIDs) + updateVisualIndex(forDisplayRow: row) redrawCells(rows: IndexSet(integer: row), tableColumnIndexes: IndexSet(integer: tableColumn)) + invalidateRowDecoration(displayRow: row) case .cellsChanged(let positions): guard !positions.isEmpty, let tableView else { return } var rowSet = IndexSet() @@ -1077,9 +1095,12 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } guard !rowSet.isEmpty, !colSet.isEmpty else { return } for row in rowSet { - visualIndex.updateRow(row, from: changeManager, displayIDs: displayIDs) + updateVisualIndex(forDisplayRow: row) } redrawCells(rows: rowSet, tableColumnIndexes: colSet) + for row in rowSet { + invalidateRowDecoration(displayRow: row) + } case .rowsInserted(let indices): guard !indices.isEmpty else { return } overlayEditor?.dismiss(commit: false) @@ -1130,21 +1151,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData refreshRowVisualState(at: row) } - func refreshVisibleRowVisualStates() { - guard let tableView else { return } - tableView.enumerateAvailableRowViews { [weak self] rowView, row in - guard let self, let dataRowView = rowView as? DataGridRowView else { return } - dataRowView.applyVisualState(self.visualState(for: row)) - } - } - - func refreshRowVisualState(at row: Int) { - guard let tableView, - let dataRowView = tableView.rowView(atRow: row, makeIfNecessary: false) as? DataGridRowView - else { return } - dataRowView.applyVisualState(visualState(for: row)) - } - func commitActiveCellEdit() { overlayEditor?.dismiss(commit: true) overlayViewer?.dismiss() @@ -1419,7 +1425,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if let delegateState = delegate?.dataGridVisualState(forRow: row) { return delegateState } - return visualIndex.visualState(for: row) + guard !visualIndex.isEmpty || !highlightRuleSet.isEmpty, + let displayed = displayRow(at: row) else { return .empty } + return visualState(of: displayed, atDisplayRow: row) } // MARK: - NSTableViewDataSource diff --git a/TablePro/Views/Results/DataGridRowGutterView.swift b/TablePro/Views/Results/DataGridRowGutterView.swift index 25df1a1085..6d972b54be 100644 --- a/TablePro/Views/Results/DataGridRowGutterView.swift +++ b/TablePro/Views/Results/DataGridRowGutterView.swift @@ -152,7 +152,7 @@ final class DataGridRowGutterView: NSView { band.row + pageOffset + 1, in: NSRect(x: cellFrame.minX, y: stripRect.minY, width: cellFrame.width, height: stripRect.height), font: font, - color: numberColor(isSelected: isSelected, emphasized: emphasized, state: state) + color: numberColor(isSelected: isSelected, emphasized: emphasized, state: state, coordinator: coordinator) ) } @@ -182,10 +182,14 @@ final class DataGridRowGutterView: NSView { return layers } - private func numberColor(isSelected: Bool, emphasized: Bool, state: RowVisualState) -> NSColor { + private func numberColor( + isSelected: Bool, + emphasized: Bool, + state: RowVisualState, + coordinator: TableViewCoordinator + ) -> NSColor { if isSelected, emphasized { return .alternateSelectedControlTextColor } - if state.isDeleted { return ThemeEngine.shared.colors.dataGrid.deletedText } - return .secondaryLabelColor + return coordinator.cellRegistry.rowNumberColor(for: state) } /// Right-aligned inside the cell frame AppKit gives the mounted row-number cell, with the insets diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index df68344e02..34680e84d9 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -37,8 +37,9 @@ class DataGridRowView: NSTableRowView { private var seededRowIndex: Int = 0 - private(set) var visualState: RowVisualState = .empty - private var rowTint: NSColor? + var visualState: RowVisualState { + coordinator?.visualState(for: rowIndex) ?? .empty + } /// Draws the row's data cells. /// @@ -201,14 +202,7 @@ class DataGridRowView: NSTableRowView { "hidden": NSNull(), ] - /// The tint derives from the row state and the active theme, so it is recomputed on every call - /// and the colour comparison below decides whether anything needs redrawing. Returning early on - /// an unchanged state would ignore the theme, which is the input a theme change moves. - func applyVisualState(_ state: RowVisualState) { - visualState = state - let nextTint = state.tint - guard !colorsEqual(rowTint, nextTint) else { return } - rowTint = nextTint + func invalidateVisualState() { needsDisplay = true } @@ -235,16 +229,38 @@ class DataGridRowView: NSTableRowView { } override func drawBackground(in dirtyRect: NSRect) { - super.drawBackground(in: dirtyRect) - if let rowTint, !isSelected { - rowTint.setFill() + drawRowBackground(in: dirtyRect) + if !isSelected, let tint = visualState.tint { + tint.setFill() bounds.fill() } drawCellSelectionFill(in: dirtyRect) } - /// A cell range on a row the table has selected is already covered by `NSTableRowView`'s own - /// selection fill, which runs after this, so only the remaining rows are painted here. + /// The row's stripe from `DataGridBodyChrome`, the owner the pinned gutter and the area past the + /// last row read too, rather than the `backgroundColor` the table assigns, which is only ever the + /// system's. A row outside a table has no stripe, and AppKit's own drawing already leaves it clear: + /// its `backgroundColor` is nil there, so it must not be read. + private func drawRowBackground(in dirtyRect: NSRect) { + guard let tableView = coordinator?.tableView else { + super.drawBackground(in: dirtyRect) + return + } + DataGridBodyChrome.rowBackgroundColor(forRow: rowIndex, of: tableView).setFill() + dirtyRect.fill() + } + + /// `NSTableRowView` fills a selected row with the system's selection colour and offers no way + /// to change it, so the theme's own selection is painted here instead. `super` is not called: + /// its fill would sit on top of this one. + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + cellSelectionFill.setFill() + dirtyRect.fill() + } + + /// A cell range on a row the table has selected is already covered by the row's own selection + /// fill, which runs after this, so only the remaining rows are painted here. private func drawCellSelectionFill(in dirtyRect: NSRect) { guard !isSelected, let coordinator, @@ -271,20 +287,13 @@ class DataGridRowView: NSTableRowView { /// emphasized accent is far too dark to sit behind text these rows do not recolour, so that /// one goes on as a tint. private var cellSelectionFill: NSColor { - guard isEmphasized else { return .unemphasizedSelectedContentBackgroundColor } - return NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.emphasizedCellSelectionAlpha) + let palette = ThemeEngine.shared.palette + guard isEmphasized else { return palette[.gridInactiveSelection] } + return palette[.gridSelection].withAlphaComponent(Self.emphasizedCellSelectionAlpha) } private static let emphasizedCellSelectionAlpha: CGFloat = 0.28 - private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { - switch (lhs, rhs) { - case (nil, nil): return true - case let (l?, r?): return l == r - default: return false - } - } - private func addForeignKeyMenuItems(to menu: NSMenu, dataColumnIndex: Int, tableRows: TableRows) { guard let coordinator, dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count else { return } let columnName = tableRows.columns[dataColumnIndex] @@ -405,7 +414,7 @@ class DataGridRowView: NSTableRowView { let menu = NSMenu() - if coordinator.changeManager.isRowDeleted(rowIndex) { + if coordinator.isRowDeleted(displayRow: rowIndex) { menu.addItem( withTitle: String(localized: "Undo Delete"), action: #selector(undoDeleteRow), keyEquivalent: "" ).target = self @@ -510,6 +519,14 @@ class DataGridRowView: NSTableRowView { jsonViewItem.target = self menu.addItem(jsonViewItem) + if dataColumnIndex >= 0, + let highlightItem = coordinator.delegate?.dataGridHighlightMenuItem( + forRow: rowIndex, + dataColumn: dataColumnIndex + ) { + menu.addItem(highlightItem) + } + let tableRows = coordinator.tableRowsProvider() addForeignKeyMenuItems(to: menu, dataColumnIndex: dataColumnIndex, tableRows: tableRows) diff --git a/TablePro/Views/Results/DataGridUpdateSnapshot.swift b/TablePro/Views/Results/DataGridUpdateSnapshot.swift index 51c3667cfa..c5a1359b4c 100644 --- a/TablePro/Views/Results/DataGridUpdateSnapshot.swift +++ b/TablePro/Views/Results/DataGridUpdateSnapshot.swift @@ -13,6 +13,7 @@ struct DataGridUpdateSnapshot: Equatable { let columns: [String] let valueFilteredIDsCount: Int? let displayFormats: [ValueDisplayFormat?] + let highlightRules: [HighlightRule] let configuration: DataGridConfiguration let isEditable: Bool let rowReorder: DataGridRowReorder diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index fa7f8e7877..b73873df6e 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -14,8 +14,9 @@ private let rowActionsLogger = Logger(subsystem: "com.TablePro", category: "Data extension TableViewCoordinator { @MainActor func undoDeleteRow(at index: Int) { - changeManager.undoRowDeletion(rowIndex: index) - visualIndex.updateRow(index, from: changeManager, displayIDs: displayIDs) + guard let rowID = rowID(forDisplayRow: index) else { return } + changeManager.undoRowDeletion(rowID: rowID) + visualIndex.updateRow(rowID, from: changeManager) repaintRows(IndexSet(integer: index)) refreshRowVisualState(at: index) } @@ -24,17 +25,6 @@ extension TableViewCoordinator { delegate?.dataGridAddRow() } - @MainActor - func undoInsertRow(at index: Int) { - delegate?.dataGridUndoInsert(at: index) - changeManager.undoRowInsertion(rowIndex: index) - var capturedDelta: Delta = .none - tableRowsMutator { rows in - capturedDelta = rows.remove(at: IndexSet(integer: index)) - } - applyDelta(capturedDelta) - } - func copyRows(at indices: Set) { let sortedIndices = indices.sorted() let tableRows = tableRowsProvider() @@ -217,7 +207,8 @@ extension TableViewCoordinator { let converter = InClauseConverter( columnIndex: columnIndex, columnTypes: tableRows.columnTypes, - escapeStringLiteral: driver?.escapeStringLiteral + escapeStringLiteral: driver?.escapeStringLiteral, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType) ) ClipboardService.shared.writeText(converter.generateInClause(rows: rows)) } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index a01aebcd71..a57c0e08da 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -19,11 +19,28 @@ struct RowVisualState: Equatable { let isDeleted: Bool let isInserted: Bool let modifiedColumns: Set + let highlight: RowHighlight + + init(isDeleted: Bool, isInserted: Bool, modifiedColumns: Set, highlight: RowHighlight = .none) { + self.isDeleted = isDeleted + self.isInserted = isInserted + self.modifiedColumns = modifiedColumns + self.highlight = highlight + } func isModified(columnIndex: Int) -> Bool { modifiedColumns.contains(columnIndex) } + func highlighted(_ highlight: RowHighlight) -> RowVisualState { + RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: modifiedColumns, + highlight: highlight + ) + } + static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) } @@ -31,20 +48,34 @@ extension RowVisualState { /// The wash a row in this state carries, read by the row and by the pinned row gutter over it so /// the two cannot disagree. @MainActor var tint: NSColor? { - if isDeleted { return ThemeEngine.shared.colors.dataGrid.deleted } - if isInserted { return ThemeEngine.shared.colors.dataGrid.inserted } - return nil + if isDeleted { return ThemeEngine.shared.palette[.gridDeleted] } + if isInserted { return ThemeEngine.shared.palette[.gridInserted] } + return highlight.rowColor?.washColor + } + + func cellHighlightColor(forColumn column: Int) -> HighlightColor? { + guard !isDeleted, !isInserted else { return nil } + return highlight.cellRule(forColumn: column)?.color + } + + func drawnHighlightRule(forColumn column: Int) -> HighlightRule? { + guard !isDeleted, !isInserted else { return nil } + if !isModified(columnIndex: column), let cellRule = highlight.cellRule(forColumn: column) { + return cellRule + } + return highlight.rowRule } } struct DataGridView: NSViewRepresentable { var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } - var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } + var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Delta) -> Delta = { _ in .none } var paginationOffsetProvider: @MainActor () -> Int = { 0 } var changeManager: AnyChangeManager let isEditable: Bool var configuration: DataGridConfiguration = .init() var displayFormats: [ValueDisplayFormat?] = [] + var highlightRules: [HighlightRule] = [] var delegate: (any DataGridViewDelegate)? var layoutPersister: (any ColumnLayoutPersisting)? /// Whether a row may be dragged to a new position, and why not when it may not. @@ -60,6 +91,7 @@ struct DataGridView: NSViewRepresentable { /// menu's row commands all read it with no grid mounted. A grid with no owner keeps the filter /// on its own coordinator, which is all a structure or create-table grid ever needs. (#2251) var valueFilter: Binding? + var displayOrderProvider: (@MainActor () -> [RowID]?)? /// The formatted text and viewport anchor for this result, owned the same way and for the same /// reason as the value filter above. The owner hands back a fresh instance whenever the inputs /// that decide the text have moved, so adopting one is always safe. (#2424) @@ -106,6 +138,7 @@ struct DataGridView: NSViewRepresentable { tableView.intercellSpacing = NSSize(width: 1, height: 0) tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) tableView.usesAutomaticRowHeights = false + DataGridBodyChrome.applyBackground(to: tableView) tableView.delegate = context.coordinator tableView.dataSource = context.coordinator @@ -129,6 +162,7 @@ struct DataGridView: NSViewRepresentable { coordinator.tableRowsMutator = tableRowsMutator coordinator.paginationOffsetProvider = paginationOffsetProvider coordinator.valueFilterBinding = valueFilter + coordinator.displayOrderProvider = displayOrderProvider if let valueFilter { coordinator.adoptValueFilter(valueFilter.wrappedValue) } @@ -145,6 +179,7 @@ struct DataGridView: NSViewRepresentable { let initialRows = tableRowsProvider() coordinator.rebuildColumnMetadataCache(from: initialRows) + coordinator.syncHighlightRules(highlightRules, tableRows: initialRows) coordinator.isRebuildingColumns = true let storedInitialLayout = coordinator.layoutDiscardingUnownedWidths( @@ -173,7 +208,7 @@ struct DataGridView: NSViewRepresentable { } installSelectionOverlay(tableView: tableView, coordinator: coordinator) - installRowGutter(scrollView: scrollView, tableView: tableView, coordinator: coordinator) + Self.installRowGutter(scrollView: scrollView, tableView: tableView, coordinator: coordinator) coordinator.attachScrollObservers(scrollView: scrollView) // Intentionally do not prime cachedRowCount/cachedColumnCount here. // They represent what NSTableView has actually rendered. Leaving them @@ -201,6 +236,7 @@ struct DataGridView: NSViewRepresentable { // before the snapshot is built. A snapshot taken from the stale order would report no // change and skip the reload. coordinator.valueFilterBinding = valueFilter + coordinator.displayOrderProvider = displayOrderProvider if let valueFilter, coordinator.valueFilterState != valueFilter.wrappedValue { coordinator.adoptValueFilter(valueFilter.wrappedValue) coordinator.recomputeValueFilteredIDs() @@ -223,6 +259,7 @@ struct DataGridView: NSViewRepresentable { columns: latestRows.columns, valueFilteredIDsCount: coordinator.valueFilteredIDs?.count, displayFormats: displayFormats, + highlightRules: highlightRules, configuration: configuration, isEditable: isEditable, rowReorder: rowReorder, @@ -320,6 +357,7 @@ struct DataGridView: NSViewRepresentable { let liveColumnWidths = latestRows.columns.isEmpty ? [:] : coordinator.currentColumnWidths() coordinator.apply(configuration: configuration, isEditable: isEditable) let schemaChanged = coordinator.rebuildColumnMetadataCache(from: latestRows) + let highlightsChanged = coordinator.syncHighlightRules(highlightRules, tableRows: latestRows) let presentationChanges = coordinator.updateColumnPresentations(from: latestRows) let needsFullReload = structureChanged || schemaChanged @@ -341,7 +379,7 @@ struct DataGridView: NSViewRepresentable { delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.recomputeValueFilteredIDs() coordinator.updateCache() - coordinator.visualIndex.rebuild(from: coordinator.changeManager, displayIDs: coordinator.displayIDs) + coordinator.visualIndex.rebuild(from: coordinator.changeManager) if !latestRows.columns.isEmpty { coordinator.isRebuildingColumns = true @@ -385,6 +423,8 @@ struct DataGridView: NSViewRepresentable { coordinator.startBackgroundPrewarm() } else if displayFormatsChanged { coordinator.reloadAfterDisplayFormatChange() + } else if highlightsChanged { + coordinator.repaintVisibleRowDecorations() } } @@ -547,11 +587,23 @@ struct DataGridView: NSViewRepresentable { /// /// Its heading is not a view of its own: `SortableHeaderView` draws it, because nothing but the /// header view's own drawing can match the headings beside it. - private func installRowGutter( + static func installRowGutter( scrollView: NSScrollView, tableView: KeyHandlingTableView, coordinator: TableViewCoordinator ) { + /// The strip is document-tall, and nothing between it and the window frame clips it on its + /// own: measured on macOS 27, AppKit reparents a floating subview into a + /// `_NSScrollViewFloatingSubviewsContainerView` whose `clipsToBounds` is false, and + /// `NSScrollView`'s is false too, which is `NSView`'s documented default for the macOS 14 + /// SDK. So the strip painted its row numbers and its column separator over whatever the + /// window stacked above the grid, every one of which is an earlier sibling and therefore + /// underneath it: the find bar, the filter panel, the key-pattern search bar, the result tab + /// bar and the banners. The scroll view is the view this code owns, so it is where the clip + /// belongs; clipping the container instead would reach into a view AppKit owns and would + /// stop working the day AppKit reparents somewhere else. + scrollView.clipsToBounds = true + let gutter = DataGridRowGutterView(frame: .zero) gutter.coordinator = coordinator tableView.addSubview(gutter) diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index f21ea8e748..9eae9ed423 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -17,15 +17,8 @@ protocol DataGridViewDelegate: AnyObject { func dataGridUndo() func dataGridRedo() func dataGridAddRow() - func dataGridUndoInsert(at index: Int) func dataGridMoveRow(from source: Int, to destination: Int) func dataGridSortStateChanged(_ state: SortState) - /// Asks the owner to approve a change that re-points the display order before it happens. - /// - /// A pending cell edit is recorded against a display row, so anything that changes which row - /// a position names re-points it: the tint moves and a later edit at that position merges - /// into another row's change. Sort, pagination and the WHERE filter already confirm; a grid - /// with no owner has no edits to lose and runs the work directly. func dataGridConfirmDisplayOrderChange(then apply: @escaping () -> Void) func dataGridFilterColumn(_ columnName: String) func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) @@ -38,6 +31,8 @@ protocol DataGridViewDelegate: AnyObject { func dataGridShowAllColumns() func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] + func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? + func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? func dataGridVisualState(forRow row: Int) -> RowVisualState? func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? func dataGridEmptySpaceMenu() -> NSMenu? @@ -68,7 +63,6 @@ extension DataGridViewDelegate { func dataGridUndo() {} func dataGridRedo() {} func dataGridAddRow() {} - func dataGridUndoInsert(at index: Int) {} func dataGridMoveRow(from source: Int, to destination: Int) {} func dataGridSortStateChanged(_ state: SortState) {} func dataGridConfirmDisplayOrderChange(then apply: @escaping () -> Void) { apply() } @@ -83,6 +77,8 @@ extension DataGridViewDelegate { func dataGridShowAllColumns() {} func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { [] } func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { [] } + func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { nil } + func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? { nil } func dataGridVisualState(forRow row: Int) -> RowVisualState? { nil } func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { nil } func dataGridEmptySpaceMenu() -> NSMenu? { nil } diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index ddb83f651c..9bbb5fa5bf 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -18,8 +18,9 @@ extension TableViewCoordinator { guard recordCellEdit(row: row, columnIndex: columnIndex, newValue: typedNewValue) != nil else { return } invalidateDisplayCache() - visualIndex.updateRow(row, from: changeManager, displayIDs: displayIDs) + updateVisualIndex(forDisplayRow: row) + invalidateRowDecoration(displayRow: row) guard let tableColumnIndex = tableColumnIndex(for: columnIndex) else { return } redrawCells(rows: IndexSet(integer: row), tableColumnIndexes: IndexSet(integer: tableColumnIndex)) } @@ -45,11 +46,10 @@ extension TableViewCoordinator { isCommittingCellEdit = true defer { isCommittingCellEdit = false } - let storageRow = tableRowsIndex(forDisplayRow: row) let columnName = tableRows.columns[columnIndex] let originalRow = Array(displayRowValues.values) changeManager.recordCellChange( - rowIndex: row, + rowID: displayRowValues.id, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, @@ -58,9 +58,9 @@ extension TableViewCoordinator { ) var delta: Delta = .none - if let storageRow { - tableRowsMutator { tableRows in - delta = tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue) + if let storageRow = tableRows.index(of: displayRowValues.id) { + delta = tableRowsMutator { tableRows in + tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue) } } cellCommitLogger.debug("recordCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift index 30b79fd1f5..ea274ff522 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift @@ -42,7 +42,7 @@ extension TableViewCoordinator { for (gridRow, rowValues) in grid.enumerated() { let targetRow = anchorRow + gridRow guard targetRow < maxRow else { break } - guard !changeManager.isRowDeleted(targetRow) else { continue } + guard !isRowDeleted(displayRow: targetRow) else { continue } for (gridCol, cellValue) in rowValues.enumerated() { let targetPosition = anchorPosition + gridCol diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 76a3341a47..26c89e7d0d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -75,7 +75,7 @@ extension TableViewCoordinator { columnType: columnType, value: cellValue(at: row, column: columnIndex), isTableEditable: isEditable, - isRowDeleted: changeManager.isRowDeleted(row), + isRowDeleted: isRowDeleted(displayRow: row), isImmutableColumn: immutable.contains(columnName), isBinaryValue: typedValue.asBytes != nil, isForeignKey: tableRows.columnForeignKeys[columnName] != nil, @@ -89,7 +89,7 @@ extension TableViewCoordinator { func handleChevronAction(row: Int, columnIndex: Int) { guard isEditable else { return } guard row >= 0, columnIndex >= 0 else { return } - guard !changeManager.isRowDeleted(row) else { return } + guard !isRowDeleted(displayRow: row) else { return } guard let tableView else { return } guard let column = tableColumnIndex(for: columnIndex) else { return } @@ -197,6 +197,7 @@ extension TableViewCoordinator { let currentValue = cellValue(at: row, column: columnIndex) ?? "" let escape = resolveDriver()?.escapeStringLiteral ?? SQLEscaping.escapeStringLiteral + let literalPrefix = SQLStringLiteralPrefix.forDatabaseType(databaseType) let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) dismissActiveCellEditorPopover() @@ -207,6 +208,7 @@ extension TableViewCoordinator { CustomValueContentView( initialValue: currentValue, escapeStringLiteral: escape, + stringLiteralPrefix: literalPrefix, onCommit: { newValue in guard let self else { return } self.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index d454f915cd..e84898cb9f 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -92,7 +92,7 @@ extension TableViewCoordinator { placeholder: DataGridCellContent.placeholder(for: rawValue) ), state: DataGridCellState( - visualState: visualState(for: row), + visualState: visualState(of: displayRow, atDisplayRow: row), isFocused: isFocused, isEditable: isEditable, isLargeDataset: isLargeDataset, @@ -142,13 +142,7 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { if let delegateRowView = delegate?.dataGridRowView(for: tableView, row: row, coordinator: self) { - // Delegate-provided row views (e.g. StructureRowViewWithMenu) must still - // pick up the deleted/inserted/modified tint. Apply the visual state if - // the row view subclasses DataGridRowView; otherwise the delegate is - // responsible for its own visual state. - if let dataGridRow = delegateRowView as? DataGridRowView { - dataGridRow.applyVisualState(visualState(for: row)) - } + (delegateRowView as? DataGridRowView)?.invalidateVisualState() return delegateRowView } let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? DataGridRowView) @@ -156,7 +150,7 @@ extension TableViewCoordinator { rowView.identifier = Self.rowViewIdentifier rowView.coordinator = self rowView.rowIndex = row - rowView.applyVisualState(visualState(for: row)) + rowView.invalidateVisualState() return rowView } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 48a6d35917..5c59aafb8d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -17,7 +17,7 @@ extension TableViewCoordinator { guard isEditable else { return .blocked } let tableRows = tableRowsProvider() guard row >= 0, columnIndex >= 0, columnIndex < tableRows.columns.count else { return .blocked } - guard !changeManager.isRowDeleted(row) else { return .blocked } + guard !isRowDeleted(displayRow: row) else { return .blocked } guard isColumnWritable(tableRows.columns[columnIndex]) else { return .blocked } @@ -47,6 +47,7 @@ extension TableViewCoordinator { /// statement can carry, so Set NULL on a MongoDB `_id`, a generated column or a /// `GENERATED ALWAYS AS IDENTITY` column marked the row edited and then wrote nothing. func isColumnWritable(_ columnName: String) -> Bool { + guard !lockedColumns.contains(columnName) else { return false } guard !changeManager.generatedColumns.contains(columnName) else { return false } let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] return !immutable.contains(columnName) @@ -66,11 +67,26 @@ extension TableViewCoordinator { /// A grid that will not take a keystroke and says nothing reads as broken. When the refusal has /// a reason the pointer already carries it as the grid's tooltip, and the beep is AppKit's own /// way of saying the attempt was heard and declined. - func refuseEditIfExplained() { + /// + /// A locked column beeps too. The rest of its grid is editable, so there is no grid-wide tooltip + /// to carry the reason, and a cell that swallowed the keystroke in silence would read as a dead + /// grid rather than a refusal. The explanation is on the dimmed pair under the list. (#2726) + func refuseEditIfExplained(columnIndex: Int? = nil) { + if let columnIndex, isLockedColumn(at: columnIndex) { + NSSound.beep() + return + } guard !isEditable, editRefusalMessage != nil else { return } NSSound.beep() } + private func isLockedColumn(at columnIndex: Int) -> Bool { + guard !lockedColumns.isEmpty else { return false } + let columns = tableRowsProvider().columns + guard columnIndex >= 0, columnIndex < columns.count else { return false } + return lockedColumns.contains(columns[columnIndex]) + } + func beginCellEdit(row: Int, tableColumnIndex: Int) { guard let tableView else { return } guard tableColumnIndex >= 0, tableColumnIndex < tableView.numberOfColumns else { return } @@ -78,7 +94,7 @@ extension TableViewCoordinator { guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier else { return } guard let columnIndex = dataColumnIndex(from: column.identifier) else { return } guard case .editable(let value) = editEligibility(row: row, columnIndex: columnIndex) else { - refuseEditIfExplained() + refuseEditIfExplained(columnIndex: columnIndex) return } showOverlayEditor( diff --git a/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift index c189968a8f..dc5f3b56c9 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift @@ -18,11 +18,7 @@ extension TableViewCoordinator { let tableRows = tableRowsProvider() guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } - let rowCount = Self.fillTargetRows( - rowCount: cachedRowCount, - isEditable: isEditable, - isRowDeleted: changeManager.isRowDeleted - ).count + let rowCount = fillTargetRows().count guard rowCount > 0 else { return } let columnName = tableRows.columns[columnIndex] @@ -45,11 +41,7 @@ extension TableViewCoordinator { } func applyFillColumn(columnIndex: Int, value: PluginCellValue) { - let targetRows = Self.fillTargetRows( - rowCount: cachedRowCount, - isEditable: isEditable, - isRowDeleted: changeManager.isRowDeleted - ) + let targetRows = fillTargetRows() guard !targetRows.isEmpty else { return } let undoManager = tableView?.window?.undoManager @@ -68,6 +60,16 @@ extension TableViewCoordinator { tableView?.reloadData() } + private func fillTargetRows() -> [Int] { + let tableRows = tableRowsProvider() + return Self.fillTargetRows(rowCount: cachedRowCount, isEditable: isEditable) { displayIndex in + guard let storageIndex = DisplayRowMapping.rowIndex( + forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows + ) else { return false } + return changeManager.isRowDeleted(tableRows.rows[storageIndex].id) + } + } + static func fillTargetRows(rowCount: Int, isEditable: Bool, isRowDeleted: (Int) -> Bool) -> [Int] { guard isEditable, rowCount > 0 else { return [] } return (0.. Bool { + let key = HighlightRuleSet.Key(rules: rules, columns: tableRows.columns, columnTypes: tableRows.columnTypes) + let compiledChanged = highlightRuleSet.key != key + if compiledChanged { + highlightRuleSet = HighlightRuleSet( + rules: rules, + columns: tableRows.columns, + columnTypes: tableRows.columnTypes + ) + } + guard displayState.highlightRuleSetKey != key else { return compiledChanged } + displayState.highlightRuleSetKey = key + displayCache.clearHighlights() + return true + } + + func highlight(for row: Row) -> RowHighlight { + guard !highlightRuleSet.isEmpty else { return .none } + if let cached = displayCache.highlight(forID: row.id) { return cached } + let resolved = highlightRuleSet.highlight(for: row.values) + displayCache.setHighlight(resolved, forID: row.id) + return resolved + } + + func pendingChangeDescription(row: Int, columnIndex: Int) -> String? { + DataGridCellTextMark.accessibilityDescription(state: visualState(for: row), columnIndex: columnIndex) + } + + func highlightDescription(row: Int, columnIndex: Int) -> String? { + guard let rule = visualState(for: row).drawnHighlightRule(forColumn: columnIndex) else { return nil } + return HighlightRuleDescription.condition(of: rule, valueLimit: HighlightRuleDescription.menuValueLimit) + } + + func invalidateRowDecoration(displayRow row: Int) { + guard let tableView, row >= 0, row < tableView.numberOfRows else { return } + if let rowView = tableView.rowView(atRow: row, makeIfNecessary: false) as? DataGridRowView { + rowView.invalidateVisualState() + rowView.redrawCells() + } + repaintRowGutter(forRow: row) + } + + func repaintVisibleRowDecorations() { + guard let tableView else { return } + tableView.enumerateAvailableRowViews { rowView, _ in + guard let dataRowView = rowView as? DataGridRowView else { return } + dataRowView.invalidateVisualState() + dataRowView.redrawCells() + } + repaintRowGutter() + } + + private func repaintRowGutter(forRow row: Int) { + guard let rowGutter, let tableView else { return } + let band = rowGutter.convert(tableView.rect(ofRow: row), from: tableView) + rowGutter.setNeedsDisplay(NSRect(x: 0, y: band.minY, width: rowGutter.bounds.width, height: band.height)) + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+RowIdentity.swift b/TablePro/Views/Results/Extensions/DataGridView+RowIdentity.swift new file mode 100644 index 0000000000..3319dc39ab --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+RowIdentity.swift @@ -0,0 +1,32 @@ +// +// DataGridView+RowIdentity.swift +// TablePro +// + +import Foundation + +extension TableViewCoordinator { + func rowID(forDisplayRow displayIndex: Int) -> RowID? { + displayRow(at: displayIndex)?.id + } + + func isRowDeleted(displayRow displayIndex: Int) -> Bool { + guard let rowID = rowID(forDisplayRow: displayIndex) else { return false } + return changeManager.isRowDeleted(rowID) + } + + func updateVisualIndex(forDisplayRow displayIndex: Int) { + guard let rowID = rowID(forDisplayRow: displayIndex) else { return } + visualIndex.updateRow(rowID, from: changeManager) + } + + /// The row's marks for a caller that has already resolved it, which every drawn cell has: + /// resolving it again costs a `TableRows` copy per cell. + func visualState(of displayed: Row, atDisplayRow row: Int) -> RowVisualState { + if let delegateState = delegate?.dataGridVisualState(forRow: row) { + return delegateState + } + guard !visualIndex.isEmpty || !highlightRuleSet.isEmpty else { return .empty } + return visualIndex.visualState(for: displayed.id).highlighted(highlight(for: displayed)) + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 2fd249928c..e0b9ea45f2 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -170,6 +170,11 @@ extension TableViewCoordinator { menu.addItem(clearAllItem) } + if let dataColumnIndex = dataColumnIndex(from: column.identifier), + let highlightItem = delegate?.dataGridHighlightValuesMenuItem(forColumn: dataColumnIndex) { + menu.addItem(highlightItem) + } + if let dataColumnIndex = dataColumnIndex(from: column.identifier) { addDisplayFormatMenu(to: menu, dataColumnIndex: dataColumnIndex, tableRows: tableRows) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift b/TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift index d2a2dc945d..a6fa5b87d5 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift @@ -39,11 +39,14 @@ extension TableViewCoordinator { return result } - /// Recomputes what the grid shows, through the same resolver the tab's owner uses. - /// - /// Both sides run one pure function over the same rows, filter, formats and database type, so - /// the grid and the readers that run without it cannot disagree about the display order. + /// Recomputes what the grid shows. A grid with an owner takes the owner's order, so the grid and + /// the readers that run without it cannot disagree about which row sits at a display position, + /// including after an edit has taken a row out of the filter's match. func recomputeValueFilteredIDs() { + if let displayOrderProvider { + valueFilteredIDs = displayOrderProvider() + return + } let tableRows = tableRowsProvider() valueFilteredIDs = GridDisplayOrderResolver.resolve( tableRows: tableRows, @@ -67,9 +70,6 @@ extension TableViewCoordinator { valueFilterState = state } - /// Confirmed before the state moves, not after: the alert's whole purpose is to let the reader - /// keep edits that this change would re-point, so the filter must not be written until they - /// have said yes. func applyValueFilter( _ filter: ColumnValueFilter?, columnName: String, @@ -121,7 +121,7 @@ extension TableViewCoordinator { func reloadAfterValueFilterChange() { recomputeValueFilteredIDs() updateCache() - visualIndex.rebuild(from: changeManager, displayIDs: displayIDs) + visualIndex.rebuild(from: changeManager) selectionController.clear() tableView?.reloadData() updateValueFilterHeaderIndicators() diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index 85b6f64b8c..2d2e5b7211 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -96,7 +96,7 @@ struct ForeignKeyPickerView: View { private var content: some View { if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index 4dad919716..e1f52d8de8 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -104,7 +104,7 @@ struct ForeignKeyPreviewView: View { .frame(height: 60) } else if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .font(.callout) .padding(10) } else if values.isEmpty { diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index a66ae62a3b..b24a121953 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -99,11 +99,11 @@ struct HexEditorBody: View { if sourceIsTruncated || isTruncated { Text(String(localized: "Truncated, read only")) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } else if !isValid, !editableHex.isEmpty { Text(String(localized: "Invalid hex")) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } Spacer() @@ -227,8 +227,8 @@ private struct HexDumpDisplayView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.secondaryLabelColor + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] textView.string = text return scrollView @@ -239,6 +239,8 @@ private struct HexDumpDisplayView: NSViewRepresentable { if textView.font != font { textView.font = font } + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] if textView.string != text { textView.string = text } @@ -265,8 +267,8 @@ private struct HexInputTextView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.labelColor + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelText] textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false @@ -288,6 +290,8 @@ private struct HexInputTextView: NSViewRepresentable { if textView.font != font { textView.font = font } + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelText] if textView.string != text, !context.coordinator.isUpdating { textView.string = text } diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index fd174460de..5c604273ef 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -21,7 +21,7 @@ struct InlineErrorBanner: View { var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) ScrollView(.vertical) { RevealedTextView(message) .font(.subheadline) @@ -63,7 +63,7 @@ struct InlineErrorBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(.red.opacity(0.08)) + .background(ThemeEngine.shared.palette.color(.statusError).opacity(0.08)) } } diff --git a/TablePro/Views/Results/JSONTreeView.swift b/TablePro/Views/Results/JSONTreeView.swift index 049e99f901..332e26dc54 100644 --- a/TablePro/Views/Results/JSONTreeView.swift +++ b/TablePro/Views/Results/JSONTreeView.swift @@ -30,7 +30,7 @@ private struct JSONTreeRowView: View { if let key = node.key { Text(key) .font(ThemeEngine.shared.valueFontEmphasizedSwiftUI) - .foregroundStyle(.blue) + .foregroundStyle(ThemeEngine.shared.palette.color(.syntaxKeyword)) .lineLimit(1) Text(":") .foregroundStyle(.secondary) diff --git a/TablePro/Views/Results/PhpTreeView.swift b/TablePro/Views/Results/PhpTreeView.swift index 7722b604a0..efdb111ab4 100644 --- a/TablePro/Views/Results/PhpTreeView.swift +++ b/TablePro/Views/Results/PhpTreeView.swift @@ -30,7 +30,7 @@ private struct PhpTreeRowView: View { if let key = node.key { Text(key) .font(ThemeEngine.shared.valueFontEmphasizedSwiftUI) - .foregroundStyle(.blue) + .foregroundStyle(ThemeEngine.shared.palette.color(.syntaxKeyword)) .lineLimit(1) if let badge = node.visibilityBadge { Text(badge) diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift index 799b3e871f..4632248492 100644 --- a/TablePro/Views/Results/ResultChartCanvas.swift +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -41,27 +41,27 @@ struct ResultChartCanvas: View { .chartYAxis { AxisMarks(position: .leading) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator)) AxisValueLabel() - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSecondaryText)) } } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 7)) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator)) AxisValueLabel(collisionResolution: .greedy(minimumSpacing: 8)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSecondaryText)) } } .chartPlotStyle { plotArea in plotArea .background( - Color(nsColor: .controlBackgroundColor).opacity(0.45), + ThemeEngine.shared.palette.color(.panelBackground).opacity(0.45), in: .rect(cornerRadius: 8) ) } diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 35d02b1ad1..c2fa3a6478 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -24,6 +24,7 @@ struct ResultStatusBar: View { let snapshot: StatusBarSnapshot let filterState: TabFilterState let columnState: StatusBarColumnState + let highlightState: StatusBarHighlightState let paginationCallbacks: PaginationCallbacks let structureFooter: StructureFooterCapability let execution: ExecutionReadout @@ -37,6 +38,7 @@ struct ResultStatusBar: View { let onStructureRemove: () -> Void @State private var showColumnPopover = false + @State private var showHighlightPopover = false var body: some View { HStack(spacing: StatusBarChrome.clusterSpacing) { @@ -50,6 +52,15 @@ struct ResultStatusBar: View { .statusBarChrome() .onChange(of: snapshot.tabId) { _, _ in showColumnPopover = false + showHighlightPopover = false + } + .onChange(of: showHighlightPopover) { _, isShown in + guard !isShown else { return } + highlightState.onDismiss() + } + .onChange(of: highlightPresentation) { previous, current in + guard previous.tabId == current.tabId, model.controls.showsHighlightRules else { return } + showHighlightPopover = true } } @@ -159,6 +170,9 @@ struct ResultStatusBar: View { if model.controls.showsColumns { columnsButton } + if model.controls.showsHighlightRules { + highlightButton + } if model.controls.showsFilters { filtersToggle } @@ -240,6 +254,43 @@ struct ResultStatusBar: View { } } + private var highlightButton: some View { + Button { + showHighlightPopover.toggle() + } label: { + Label { + Text("Highlight Rules") + } icon: { + Image(systemName: "highlighter") + } + } + .labelStyle(.iconOnly) + .controlSize(.small) + .disabled(highlightState.columns.isEmpty) + .help(String(localized: "Highlight Rules")) + .accessibilityLabel(String(localized: "Highlight Rules")) + .accessibilityValue(highlightAccessibilityValue) + .accessibilityIdentifier("result-status-highlight") + .popover(isPresented: $showHighlightPopover, arrowEdge: .top) { + HighlightRulesPopover( + columns: highlightState.columns, + rules: highlightState.rules, + isPersisted: highlightState.isPersisted, + onChange: highlightState.onChange + ) + } + } + + private var highlightPresentation: HighlightPresentationRequest { + HighlightPresentationRequest(tabId: snapshot.tabId, count: highlightState.presentationRequest) + } + + private var highlightAccessibilityValue: String { + let count = highlightState.activeRuleCount + guard count > 0 else { return String(localized: "No highlight rules") } + return String(format: String(localized: "%d rules"), count) + } + private var filtersToggle: some View { Toggle(isOn: Binding(get: { filterState.isVisible }, set: { _ in onToggleFilters() })) { Label { diff --git a/TablePro/Views/Results/ResultStatusInputs.swift b/TablePro/Views/Results/ResultStatusInputs.swift index 5473a9d500..3c060b58d1 100644 --- a/TablePro/Views/Results/ResultStatusInputs.swift +++ b/TablePro/Views/Results/ResultStatusInputs.swift @@ -16,6 +16,24 @@ struct PaginationCallbacks { let onRequestExactCount: () -> Void } +struct HighlightPresentationRequest: Equatable { + let tabId: UUID? + let count: Int +} + +struct StatusBarHighlightState { + let rules: [HighlightRule] + let columns: [String] + let isPersisted: Bool + let presentationRequest: Int + let onChange: ([HighlightRule]) -> Void + let onDismiss: () -> Void + + var activeRuleCount: Int { + rules.filter { $0.isEnabled && $0.isValid }.count + } +} + struct StatusBarColumnState { let hidden: Set let columns: [GridColumnEntry] diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift index 3954698442..1488dcae13 100644 --- a/TablePro/Views/Results/ResultSuccessView.swift +++ b/TablePro/Views/Results/ResultSuccessView.swift @@ -28,7 +28,7 @@ struct ResultSuccessView: View { Spacer() Image(systemName: "checkmark.circle.fill") .font(.largeTitle) - .foregroundStyle(.green) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusSuccess)) Text(primaryMessage) .font(.body) if let time = executionTime { diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index 7504bbdb51..d96114e889 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -10,7 +10,7 @@ internal struct ResultsJsonView: View { let tableRows: TableRows let selectedRowIndices: Set let displayIDs: [RowID]? - let deletedRowIndices: Set + let deletedRowIDs: Set let valueFilter: GridValueFilterState let dataRevision: Int let displayRevision: Int @@ -32,7 +32,7 @@ internal struct ResultsJsonView: View { tableRows: TableRows, selectedRowIndices: Set, displayIDs: [RowID]?, - deletedRowIndices: Set, + deletedRowIDs: Set, valueFilter: GridValueFilterState, dataRevision: Int, displayRevision: Int, @@ -41,7 +41,7 @@ internal struct ResultsJsonView: View { self.tableRows = tableRows self.selectedRowIndices = selectedRowIndices self.displayIDs = displayIDs - self.deletedRowIndices = deletedRowIndices + self.deletedRowIDs = deletedRowIDs self.valueFilter = valueFilter self.dataRevision = dataRevision self.displayRevision = displayRevision @@ -58,7 +58,7 @@ internal struct ResultsJsonView: View { let dataRevision: Int let displayRevision: Int let selectedRowIndices: Set - let deletedRowIndices: Set + let deletedRowIDs: Set let valueFilter: GridValueFilterState let hiddenColumns: Set let columnOrder: [String]? @@ -69,7 +69,7 @@ internal struct ResultsJsonView: View { dataRevision: dataRevision, displayRevision: displayRevision, selectedRowIndices: selectedRowIndices, - deletedRowIndices: deletedRowIndices, + deletedRowIDs: deletedRowIDs, valueFilter: valueFilter, hiddenColumns: columnLayout.hiddenColumns, columnOrder: columnLayout.columnOrder @@ -209,7 +209,7 @@ internal struct ResultsJsonView: View { let snapshot = tableRows let ids = displayIDs let selectedIndices = selectedRowIndices - let deletedIndices = deletedRowIndices + let deletedIDs = deletedRowIDs let layout = columnLayout let result = await Task.detached(priority: .userInitiated) { @@ -217,7 +217,7 @@ internal struct ResultsJsonView: View { tableRows: snapshot, displayIDs: ids, selectedIndices: selectedIndices, - deletedIndices: deletedIndices, + deletedRowIDs: deletedIDs, columnLayout: layout ) }.value @@ -251,14 +251,14 @@ internal struct ResultsJsonView: View { tableRows: TableRows, displayIDs: [RowID]?, selectedIndices: Set, - deletedIndices: Set = [], + deletedRowIDs: Set = [], columnLayout: ColumnLayoutState ) -> RenderedJson { let output = ResultJsonSerializer.serialize( tableRows: tableRows, displayIDs: displayIDs, selectedDisplayIndices: selectedIndices, - deletedDisplayIndices: deletedIndices, + deletedRowIDs: deletedRowIDs, columns: .fromColumnLayout(columnLayout, columns: tableRows.columns) ) return RenderedJson( diff --git a/TablePro/Views/Results/RowVisualIndex.swift b/TablePro/Views/Results/RowVisualIndex.swift index 83dac3f93a..9e0d71d1c5 100644 --- a/TablePro/Views/Results/RowVisualIndex.swift +++ b/TablePro/Views/Results/RowVisualIndex.swift @@ -7,67 +7,57 @@ import Foundation @MainActor final class RowVisualIndex { - private var states: [Int: RowVisualState] = [:] + private var states: [RowID: RowVisualState] = [:] - func visualState(for row: Int) -> RowVisualState { - states[row] ?? .empty + var isEmpty: Bool { states.isEmpty } + + func visualState(for rowID: RowID) -> RowVisualState { + states[rowID] ?? .empty } func clear() { states.removeAll(keepingCapacity: true) } - func rebuild(from changeManager: AnyChangeManager, displayIDs: [RowID]?) { + func rebuild(from changeManager: AnyChangeManager) { states.removeAll(keepingCapacity: true) - let insertedRowIndices = Self.insertedRowIndices( - from: changeManager, - displayIDs: displayIDs - ) - - if !changeManager.hasChanges && insertedRowIndices.isEmpty { - return - } + let insertedRowIDs = changeManager.insertedRowIDs + guard changeManager.hasChanges || !insertedRowIDs.isEmpty else { return } for rowChange in changeManager.rowChanges { - states[rowChange.rowIndex] = Self.makeState( + states[rowChange.rowID] = Self.makeState( for: rowChange, - inserted: insertedRowIndices.contains(rowChange.rowIndex) + inserted: insertedRowIDs.contains(rowChange.rowID) ) } - for rowIndex in insertedRowIndices where states[rowIndex] == nil { - states[rowIndex] = RowVisualState( - isDeleted: false, - isInserted: true, - modifiedColumns: [] - ) + for rowID in insertedRowIDs where states[rowID] == nil { + states[rowID] = Self.insertedState } } - func updateRow(_ rowIndex: Int, from changeManager: AnyChangeManager, displayIDs: [RowID]?) { - let isInsertedDisplay = Self.isRowInsertedAtDisplayIndex( - rowIndex, - changeManager: changeManager, - displayIDs: displayIDs - ) + func updateRow(_ rowID: RowID, from changeManager: AnyChangeManager) { + let isInserted = changeManager.insertedRowIDs.contains(rowID) - if let rowChange = changeManager.rowChanges.first(where: { $0.rowIndex == rowIndex }) { - states[rowIndex] = Self.makeState(for: rowChange, inserted: isInsertedDisplay) + if let rowChange = changeManager.rowChanges.first(where: { $0.rowID == rowID }) { + states[rowID] = Self.makeState(for: rowChange, inserted: isInserted) return } - if isInsertedDisplay { - states[rowIndex] = RowVisualState( - isDeleted: false, - isInserted: true, - modifiedColumns: [] - ) + if isInserted { + states[rowID] = Self.insertedState } else { - states.removeValue(forKey: rowIndex) + states.removeValue(forKey: rowID) } } + private static let insertedState = RowVisualState( + isDeleted: false, + isInserted: true, + modifiedColumns: [] + ) + private static func makeState(for rowChange: RowChange, inserted: Bool) -> RowVisualState { let isDeleted = rowChange.type == .delete let isInserted = inserted || rowChange.type == .insert @@ -80,28 +70,4 @@ final class RowVisualIndex { modifiedColumns: modifiedColumns ) } - - private static func insertedRowIndices( - from changeManager: AnyChangeManager, - displayIDs: [RowID]? - ) -> Set { - guard let displayIDs else { return changeManager.insertedRowIndices } - var indices = Set() - for (displayIndex, id) in displayIDs.enumerated() where id.isInserted { - indices.insert(displayIndex) - } - return indices - } - - private static func isRowInsertedAtDisplayIndex( - _ rowIndex: Int, - changeManager: AnyChangeManager, - displayIDs: [RowID]? - ) -> Bool { - if let displayIDs { - guard rowIndex >= 0, rowIndex < displayIDs.count else { return false } - return displayIDs[rowIndex].isInserted - } - return changeManager.insertedRowIndices.contains(rowIndex) - } } diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index bd1cd85a65..f12f6d0709 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -37,7 +37,7 @@ final class GridSelectionOverlay: NSView { let totalRows = tableView.numberOfRows let editingCell = activeOverlayCell(in: coordinator) - NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.borderAlpha).setStroke() + ThemeEngine.shared.palette[.gridSelection].withAlphaComponent(Self.borderAlpha).setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, coordinator: coordinator) else { continue } guard frame.intersects(dirtyRect) else { continue } diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index e8f34936e5..02cfdf6366 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -190,7 +190,9 @@ final class SortableHeaderCell: NSTableHeaderCell { } private func foregroundColor(emphasized: Bool) -> NSColor { - emphasized ? .alternateSelectedControlTextColor : .headerTextColor + emphasized + ? ThemeEngine.shared.palette[.gridSelectionText] + : ThemeEngine.shared.palette[.gridHeaderText] } private func commentColor(emphasized: Bool) -> NSColor { diff --git a/TablePro/Views/Results/SortableHeaderChrome.swift b/TablePro/Views/Results/SortableHeaderChrome.swift index 8eb7aba26c..18076f1ebc 100644 --- a/TablePro/Views/Results/SortableHeaderChrome.swift +++ b/TablePro/Views/Results/SortableHeaderChrome.swift @@ -11,12 +11,12 @@ enum SortableHeaderChrome { static let columnDividerHeight: CGFloat = 16 static func fillBackground(_ rect: NSRect) { - NSColor.windowBackgroundColor.setFill() + ThemeEngine.shared.palette[.gridHeaderBackground].setFill() rect.fill() } static func drawBottomSeparator(in bounds: NSRect) { - NSColor.separatorColor.setFill() + ThemeEngine.shared.palette[.gridLine].setFill() NSRect( x: bounds.minX, y: bounds.maxY - separatorThickness, @@ -27,7 +27,7 @@ enum SortableHeaderChrome { static func drawColumnDivider(in cellFrame: NSRect) { let dividerHeight = min(columnDividerHeight, cellFrame.height) - NSColor.separatorColor.setFill() + ThemeEngine.shared.palette[.gridLine].setFill() NSRect( x: cellFrame.maxX - separatorThickness, y: cellFrame.midY - dividerHeight / 2, diff --git a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index d2f150327d..20ab3e650e 100644 --- a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -94,11 +94,11 @@ internal struct BlobHexEditorView: View { if isTruncated { Text("Truncated, read only") .font(.caption2) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } else if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty { Text("Invalid hex") .font(.caption2) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } } } diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index affe056c61..3f19742212 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -106,7 +106,7 @@ internal struct PendingStatePill: View { .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .background(ThemeEngine.shared.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift index 369a9da12c..d0c4bfbe0b 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift @@ -34,7 +34,7 @@ internal struct ImageFieldView: View { ) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .task(id: context.value.wrappedValue) { data = context.value.wrappedValue.storedBytes } diff --git a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift index 65428a310f..c4da8a40f0 100644 --- a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift @@ -29,7 +29,7 @@ internal struct JsonEditorView: View { ) { JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .overlay(alignment: .bottomTrailing) { actionButtons } } .onChange(of: displayText) { propagateEdit() } diff --git a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index e2b38f2a64..f0a875d36f 100644 --- a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -26,7 +26,7 @@ internal struct MultiLineEditorView: View { movesFocusOnTab: true ) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .overlay(alignment: .topLeading) { placeholder } .overlay(alignment: .bottomTrailing) { popOutButton } } diff --git a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift index cf499ad52e..0d919505b1 100644 --- a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift @@ -18,6 +18,6 @@ internal struct PhpSerializedFieldView: View { .frame(height: isExpanded ? ResizableFieldMetrics.expandedHeight : nil) .frame(minHeight: isExpanded ? nil : 80, maxHeight: isExpanded ? nil : 200) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift index 6438601394..ad065ee488 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift @@ -39,7 +39,7 @@ internal struct ResizableEditorContainer: View { private var resizeHandle: some View { Capsule() - .fill(Color(nsColor: .tertiaryLabelColor)) + .fill(ThemeEngine.shared.palette.color(.panelTertiaryText)) .frame(width: 26, height: 4) .opacity(isHandleHovered ? 1 : 0.5) .frame(maxWidth: .infinity, minHeight: 11) diff --git a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift index f960266bb5..d6abbbe425 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift @@ -29,7 +29,7 @@ internal struct SingleLineEditorView: View { .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) .padding(.horizontal, 6) .padding(.vertical, 4) - .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .background(ThemeEngine.shared.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift index dc41a30a68..76c2bc2a95 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift @@ -43,6 +43,7 @@ internal struct ValuePickerFieldView: View { CustomValueContentView( initialValue: context.value.wrappedValue, escapeStringLiteral: escapeStringLiteral, + stringLiteralPrefix: stringLiteralPrefix, onCommit: { context.value.wrappedValue = $0 }, onDismiss: { isCustomPresented = false } ) @@ -84,4 +85,15 @@ internal struct ValuePickerFieldView: View { } return driver.escapeStringLiteral } + + /// From the same connection the escaping comes from, so a default typed here is written the + /// way the engine reads it back. No connection means no prefix, which is what every engine but + /// SQL Server wants anyway. + private var stringLiteralPrefix: String { + guard let connectionId = context.userDefinedTypeScope?.connectionId, + let driver = DatabaseManager.shared.driver(for: connectionId) else { + return "" + } + return SQLStringLiteralPrefix.forDatabaseType(driver.connection.type) + } } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift index 049a0354bc..8456937411 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift @@ -21,14 +21,14 @@ struct JSONRowColors { @MainActor static func current() -> JSONRowColors { - let colors = ThemeEngine.shared.colors.editor + let palette = ThemeEngine.shared.palette return JSONRowColors( - key: colors.keywordSwiftUI, - string: colors.stringSwiftUI, - number: colors.numberSwiftUI, - literal: colors.nullSwiftUI, - punctuation: colors.textSwiftUI, - placeholder: colors.commentSwiftUI + key: palette.color(.syntaxKeyword), + string: palette.color(.syntaxString), + number: palette.color(.syntaxNumber), + literal: palette.color(.syntaxNull), + punctuation: palette.color(.editorText), + placeholder: palette.color(.syntaxComment) ) } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index 7e157deac3..d6926bc4da 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -66,7 +66,7 @@ struct JSONRowInspectorView: View { ) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.red.opacity(0.6)) + .strokeBorder(ThemeEngine.shared.palette.color(.statusError).opacity(0.6)) .opacity(viewModel.isFilterInvalid ? 1 : 0) ) .help(viewModel.isFilterInvalid @@ -127,7 +127,7 @@ struct JSONRowInspectorView: View { .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .topLeading) } - .background(Color(nsColor: ThemeEngine.shared.colors.editor.background)) + .background(ThemeEngine.shared.palette.color(.editorBackground)) .accessibilityLabel(String(localized: "Row as JSON")) } } diff --git a/TablePro/Views/ServerDashboard/MetricsBarView.swift b/TablePro/Views/ServerDashboard/MetricsBarView.swift index 89f4c503d6..61d9839754 100644 --- a/TablePro/Views/ServerDashboard/MetricsBarView.swift +++ b/TablePro/Views/ServerDashboard/MetricsBarView.swift @@ -17,7 +17,7 @@ struct MetricsBarView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } } .padding(.horizontal, 12) diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 93300d3e49..e211f48cb8 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -19,7 +19,7 @@ struct SessionsTableView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } } .padding(.horizontal, 12) @@ -86,10 +86,10 @@ struct SessionsTableView: View { private func stateColor(_ state: String) -> Color { switch state.lowercased() { - case "active", "running": return .green + case "active", "running": return ThemeEngine.shared.palette.color(.statusSuccess) case "idle": return .secondary - case "idle in transaction": return .orange - case "waiting", "locked": return .red + case "idle in transaction": return ThemeEngine.shared.palette.color(.statusWarning) + case "waiting", "locked": return ThemeEngine.shared.palette.color(.statusError) default: return .primary } } diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index 5c2358ef5e..12aa31a35d 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -19,7 +19,7 @@ struct SlowQueryListView: View { Image(systemName: "exclamationmark.triangle.fill") } .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } } .padding(.horizontal, 12) @@ -49,7 +49,7 @@ struct SlowQueryListView: View { Text(query.duration) .font(.system(.caption, design: .monospaced)) .monospacedDigit() - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .frame(width: 50, alignment: .trailing) VStack(alignment: .leading, spacing: 2) { diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift index 5eb4690197..c37f98a5ae 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift @@ -1,306 +1,116 @@ -// -// ThemeEditorColorsSection.swift -// TablePro -// - import AppKit import os import SwiftUI -// MARK: - HexColorPicker - -struct HexColorPicker: View { - let label: String - @Binding var hex: String - - var body: some View { - let colorBinding = Binding( - get: { hex.swiftUIColor }, - set: { newColor in - if let converted = NSColor(newColor).usingColorSpace(.sRGB) { - hex = converted.hexString - } - } - ) - ColorPicker(label, selection: colorBinding, supportsOpacity: true) - } -} - -// MARK: - ThemeEditorColorsSection - +/// One well per registered slot, driven by `ThemeSlot.allCases`, so a slot the app gains appears +/// here without a second hand-maintained list to keep in step. internal struct ThemeEditorColorsSection: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorColorsSection") - private var engine: ThemeEngine { ThemeEngine.shared } - private var theme: ThemeDefinition { engine.activeTheme } - - var body: some View { - Form { - editorSection - syntaxSection - dataGridSection - interfaceSection - statusSection - badgesSection - sidebarSection - toolbarSection - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } + internal let theme: ThemeDefinition - // MARK: - Editor + @State private var draft: ThemeDefinition? + @State private var saveTask: Task? - private var editorSection: some View { - Section(String(localized: "Editor")) { - LabeledContent(String(localized: "Background")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.background)) - } - LabeledContent(String(localized: "Text")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.text)) - } - LabeledContent(String(localized: "Cursor")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.cursor)) - } - LabeledContent(String(localized: "Current Line")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.currentLineHighlight)) - } - LabeledContent(String(localized: "Selection")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.selection)) - } - LabeledContent(String(localized: "Line Number")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.lineNumber)) - } - LabeledContent(String(localized: "Invisibles")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.invisibles)) - } - } - } + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorColors") + private static let saveDelay = Duration.milliseconds(250) - private var syntaxSection: some View { - Section(String(localized: "Syntax Colors")) { - LabeledContent(String(localized: "Keyword")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.keyword)) - } - LabeledContent(String(localized: "String")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.string)) - } - LabeledContent(String(localized: "Number")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.number)) - } - LabeledContent(String(localized: "Comment")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.comment)) - } - LabeledContent(String(localized: "NULL")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.null)) - } - LabeledContent(String(localized: "Operator")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.operator)) - } - LabeledContent(String(localized: "Function")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.function)) - } - LabeledContent(String(localized: "Type")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.type)) - } - } + private var edited: ThemeDefinition { + guard let draft, draft.id == theme.id else { return theme } + return draft } - // MARK: - Data Grid - - private var dataGridSection: some View { - Section(String(localized: "Data Grid")) { - LabeledContent(String(localized: "Background")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.background)) - } - LabeledContent(String(localized: "Text")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.text)) - } - LabeledContent(String(localized: "Alternate Row")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.alternateRow)) - } - LabeledContent(String(localized: "NULL Value")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.nullValue)) - } - LabeledContent(String(localized: "Bool True")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.boolTrue)) - } - LabeledContent(String(localized: "Bool False")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.boolFalse)) - } - LabeledContent(String(localized: "Row Number")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.rowNumber)) - } - LabeledContent(String(localized: "Modified")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.modified)) - } - LabeledContent(String(localized: "Inserted")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.inserted)) - } - LabeledContent(String(localized: "Deleted")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.deleted)) - } - LabeledContent(String(localized: "Deleted Text")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.deletedText)) - } - LabeledContent(String(localized: "Focus Border")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.focusBorder)) + internal var body: some View { + Form { + ForEach(ThemeSlotGroup.allCases, id: \.self) { group in + Section(group.label) { + ForEach(group.slots, id: \.self) { slot in + row(for: slot) + } + } } } + .formStyle(.grouped) + .onDisappear { flush() } } - // MARK: - Interface + private func row(for slot: ThemeSlot) -> some View { + let value = edited[keyPath: slot.keyPath] - private var interfaceSection: some View { - Section(String(localized: "Interface")) { - optionalColorRow(String(localized: "Window Background"), keyPath: \.ui.windowBackground, - fallback: .windowBackgroundColor) - optionalColorRow(String(localized: "Control Background"), keyPath: \.ui.controlBackground, - fallback: .controlBackgroundColor) - optionalColorRow(String(localized: "Card Background"), keyPath: \.ui.cardBackground, - fallback: .controlBackgroundColor) - optionalColorRow(String(localized: "Border"), keyPath: \.ui.border, - fallback: .separatorColor) - optionalColorRow(String(localized: "Primary Text"), keyPath: \.ui.primaryText, - fallback: .labelColor) - optionalColorRow(String(localized: "Secondary Text"), keyPath: \.ui.secondaryText, - fallback: .secondaryLabelColor) - optionalColorRow(String(localized: "Tertiary Text"), keyPath: \.ui.tertiaryText, - fallback: .tertiaryLabelColor) - optionalColorRow(String(localized: "Selection"), keyPath: \.ui.selectionBackground, - fallback: .selectedContentBackgroundColor) - optionalColorRow(String(localized: "Hover"), keyPath: \.ui.hoverBackground, - fallback: .unemphasizedSelectedContentBackgroundColor) - } - } - - private var statusSection: some View { - Section(String(localized: "Status Colors")) { - LabeledContent(String(localized: "Success")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.success)) - } - LabeledContent(String(localized: "Warning")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.warning)) - } - LabeledContent(String(localized: "Error")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.error)) - } - LabeledContent(String(localized: "Info")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.info)) - } - } - } + return LabeledContent(slot.label) { + HStack(spacing: 8) { + if case let .system(name) = value { + Text(name.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } - private var badgesSection: some View { - Section(String(localized: "Badges")) { - LabeledContent(String(localized: "Badge Background")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.badges.background)) + ColorPicker("", selection: binding(for: slot), supportsOpacity: true) + .labelsHidden() } - LabeledContent(String(localized: "Primary Key")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.badges.primaryKey)) - } - LabeledContent(String(localized: "Auto Increment")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.badges.autoIncrement)) + .contextMenu { + if case .hex = value, case let .system(name) = BuiltInThemes.default(for: theme.appearance)[keyPath: slot.keyPath] { + Button(String(format: String(localized: "Use System Color (%@)"), name.rawValue)) { + write(.system(name), to: slot) + } + } } } } - // MARK: - Sidebar - - private var sidebarSection: some View { - Section(String(localized: "Sidebar")) { - optionalColorRow(String(localized: "Background"), keyPath: \.sidebar.background, - fallback: .windowBackgroundColor) - optionalColorRow(String(localized: "Text"), keyPath: \.sidebar.text, - fallback: .labelColor) - optionalColorRow(String(localized: "Selected Item"), keyPath: \.sidebar.selectedItem, - fallback: .selectedContentBackgroundColor) - optionalColorRow(String(localized: "Hover"), keyPath: \.sidebar.hover, - fallback: .unemphasizedSelectedContentBackgroundColor) - optionalColorRow(String(localized: "Section Header"), keyPath: \.sidebar.sectionHeader, - fallback: .secondaryLabelColor) - } + private var editedAppearance: NSAppearance? { + NSAppearance(named: theme.appearance == .dark ? .darkAqua : .aqua) } - // MARK: - Toolbar + private func resolved(_ value: ThemeColorValue) -> NSColor { + guard value.isSystem, let editedAppearance else { return value.nsColor } - private var toolbarSection: some View { - Section(String(localized: "Toolbar")) { - optionalColorRow(String(localized: "Secondary Text"), keyPath: \.toolbar.secondaryText, - fallback: .secondaryLabelColor) - optionalColorRow(String(localized: "Tertiary Text"), keyPath: \.toolbar.tertiaryText, - fallback: .tertiaryLabelColor) + var color = value.nsColor + editedAppearance.performAsCurrentDrawingAppearance { + color = value.nsColor.usingColorSpace(.sRGB) ?? value.nsColor } + return color } - // MARK: - Helpers - - private func colorBinding(for keyPath: WritableKeyPath) -> Binding { + private func binding(for slot: ThemeSlot) -> Binding { Binding( - get: { theme[keyPath: keyPath] }, - set: { newValue in - guard theme.isEditable else { return } - var updated = theme - updated[keyPath: keyPath] = newValue - do { - try engine.saveUserTheme(updated) - } catch { - Self.logger.error("Failed to save theme: \(error.localizedDescription, privacy: .public)") - } + get: { Color(nsColor: resolved(edited[keyPath: slot.keyPath])) }, + set: { newColor in + write(.hex(HexColor.string(from: NSColor(newColor))), to: slot) } ) } - private func optionalColorBinding( - for keyPath: WritableKeyPath, - fallback: NSColor - ) -> Binding { - Binding( - get: { - if let hex = theme[keyPath: keyPath] { - return hex - } - return (fallback.usingColorSpace(.sRGB) ?? fallback).hexString - }, - set: { newValue in - guard theme.isEditable else { return } - var updated = theme - updated[keyPath: keyPath] = newValue - do { - try engine.saveUserTheme(updated) - } catch { - Self.logger.error("Failed to save theme: \(error.localizedDescription, privacy: .public)") - } - } - ) + /// The well emits on every drag tick, and each one used to write the file, rescan the themes + /// directory and re-activate. The draft absorbs the ticks and one save follows the settle. + private func write(_ value: ThemeColorValue, to slot: ThemeSlot) { + var updated = edited + updated[keyPath: slot.keyPath] = value + draft = updated + + saveTask?.cancel() + saveTask = Task { @MainActor in + try? await Task.sleep(for: Self.saveDelay) + guard !Task.isCancelled else { return } + save(updated) + } } - @ViewBuilder - private func optionalColorRow( - _ label: String, - keyPath: WritableKeyPath, - fallback: NSColor - ) -> some View { - LabeledContent(label) { - HStack(spacing: 4) { - HexColorPicker(label: "", hex: optionalColorBinding(for: keyPath, fallback: fallback)) - if theme[keyPath: keyPath] != nil { - Button { - guard theme.isEditable else { return } - var updated = theme - updated[keyPath: keyPath] = nil - do { - try engine.saveUserTheme(updated) - } catch { - Self.logger.error("Failed to save theme: \(error.localizedDescription, privacy: .public)") - } - } label: { - Image(systemName: "arrow.counterclockwise") - .font(.caption) - } - .buttonStyle(.borderless) - .help(String(localized: "Reset to System Default")) - } - } + private func flush() { + saveTask?.cancel() + saveTask = nil + guard let draft, draft != theme else { return } + save(draft) + } + + private func save(_ updated: ThemeDefinition) { + do { + try ThemeCatalog.shared.save(updated) + let appearance = AppSettingsManager.shared.appearance + ThemeEngine.shared.reapply( + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) + } catch { + Self.logger.error("Could not save theme: \(error.localizedDescription)") } } } diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift deleted file mode 100644 index 89c9b7fc39..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift +++ /dev/null @@ -1,135 +0,0 @@ -import AppKit -import SwiftUI - -struct ThemeEditorFontsSection: View { - var onThemeDuplicated: ((ThemeDefinition) -> Void)? - - private var engine: ThemeEngine { ThemeEngine.shared } - - @State private var editingTheme: ThemeDefinition? - - private var theme: ThemeDefinition { engine.activeTheme } - - private var currentThemeFonts: ThemeFonts { - editingTheme?.fonts ?? theme.fonts - } - - var body: some View { - Form { - editorFontSection - dataGridFontSection - previewSection - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - .onChange(of: engine.activeTheme.id) { - editingTheme = nil - } - } - - // MARK: - Editor Font - - private var editorFontSection: some View { - Section(String(localized: "Editor Font")) { - fontPicker( - label: String(localized: "Family"), - selection: currentThemeFonts.editorFontFamily, - onChange: { newFamily in - updateFont { $0.editorFontFamily = newFamily } - } - ) - sizePicker( - label: String(localized: "Size"), - value: currentThemeFonts.editorFontSize, - range: 11...18, - onChange: { newSize in - updateFont { $0.editorFontSize = newSize } - } - ) - } - } - - // MARK: - Data Grid Font - - private var dataGridFontSection: some View { - Section(String(localized: "Data Grid Font")) { - fontPicker( - label: String(localized: "Family"), - selection: currentThemeFonts.dataGridFontFamily, - onChange: { newFamily in - updateFont { $0.dataGridFontFamily = newFamily } - } - ) - sizePicker( - label: String(localized: "Size"), - value: currentThemeFonts.dataGridFontSize, - range: 10...18, - onChange: { newSize in - updateFont { $0.dataGridFontSize = newSize } - } - ) - } - } - - // MARK: - Preview - - private var previewSection: some View { - Section(String(localized: "Preview")) { - let fonts = currentThemeFonts - let editorFont = EditorFontResolver.resolve( - familyId: fonts.editorFontFamily, - size: CGFloat(fonts.editorFontSize) - ) - - Text("SELECT * FROM users WHERE id = 42;") - .font(Font(editorFont)) - .foregroundStyle(theme.editor.text.swiftUIColor) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(theme.editor.background.swiftUIColor) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } - } - - // MARK: - Helpers - - private func fontPicker(label: String, selection: String, onChange: @escaping (String) -> Void) -> some View { - Picker(label, selection: Binding( - get: { selection }, - set: { onChange($0) } - )) { - ForEach(EditorFontResolver.availableMonospacedFamilies) { font in - Text(font.displayName).tag(font.id) - } - } - } - - private func sizePicker(label: String, value: Int, range: ClosedRange, - onChange: @escaping (Int) -> Void) -> some View { - Picker(label, selection: Binding( - get: { value }, - set: { onChange($0) } - )) { - ForEach(range, id: \.self) { size in - Text(verbatim: "\(size) pt").tag(size) - } - } - } - - private func updateFont(_ mutate: (inout ThemeFonts) -> Void) { - let base = editingTheme ?? theme - - if base.isBuiltIn { - var copy = engine.duplicateTheme(base, newName: base.name + " (Custom)") - mutate(©.fonts) - try? engine.saveUserTheme(copy) - editingTheme = copy - onThemeDuplicated?(copy) - } else { - var updated = base - mutate(&updated.fonts) - try? engine.saveUserTheme(updated) - editingTheme = updated - } - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift index e479f37be5..64e1dbaa58 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift @@ -1,57 +1,35 @@ -// -// ThemeEditorView.swift -// TablePro -// -// Right panel of the appearance HSplitView: theme header, accent color, and tabbed editor sections. -// - +import os import SwiftUI +/// Edits the theme the slot holds, not the theme in effect. Reading `activeTheme` here meant that +/// with the Mac in light mode and the pane set to Editing: Dark, every edit, duplicate and delete +/// landed on the light theme, and the dark slot was then pointed at it. internal struct ThemeEditorView: View { - @Binding var selectedThemeId: String - - private var engine: ThemeEngine { ThemeEngine.shared } - private var theme: ThemeDefinition { engine.activeTheme } - private var isEditable: Bool { theme.isEditable } - - @State private var activeTab: EditorTab = .fonts + @Binding internal var selectedThemeId: String + internal let slotAppearance: ThemeAppearance @State private var errorMessage: String? @State private var showError = false - private enum EditorTab: String, CaseIterable { - case fonts = "Fonts" - case colors = "Colors" + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorView") - var localizedName: String { - switch self { - case .fonts: return String(localized: "Fonts") - case .colors: return String(localized: "Colors") - } - } + private var catalog: ThemeCatalog { ThemeCatalog.shared } + + private var theme: ThemeDefinition { + catalog.theme(id: selectedThemeId) ?? BuiltInThemes.default(for: slotAppearance) } - var body: some View { + internal var body: some View { VStack(spacing: 0) { - Text(theme.name) - .font(.title3.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 8) - - Picker("", selection: $activeTab) { - ForEach(EditorTab.allCases, id: \.self) { tab in - Text(tab.localizedName).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.bottom, 8) + header Divider() - tabContent + if theme.isEditable { + ThemeEditorColorsSection(theme: theme) + } else { + duplicatePrompt + } } .alert(String(localized: "Error"), isPresented: $showError) { Button(String(localized: "OK")) {} @@ -62,20 +40,19 @@ internal struct ThemeEditorView: View { } } - @ViewBuilder - private var tabContent: some View { - switch activeTab { - case .fonts: - ThemeEditorFontsSection(onThemeDuplicated: { newTheme in - selectedThemeId = newTheme.id - }) - case .colors: - if isEditable { - ThemeEditorColorsSection() - } else { - duplicatePrompt - } + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text(theme.name) + .font(.title3.weight(.semibold)) + + Text(theme.author.isEmpty ? String(localized: "Custom theme") : theme.author) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 10) } private var duplicatePrompt: some View { @@ -92,11 +69,11 @@ internal struct ThemeEditorView: View { .font(.body) .foregroundStyle(.secondary) - Text(String(localized: "Duplicate it to customize colors.")) + Text("Duplicate it to change its colors.") .font(.subheadline) .foregroundStyle(.tertiary) - Button(String(localized: "Duplicate Theme")) { + Button("Duplicate Theme") { duplicateAndSelect() } .controlSize(.large) @@ -107,12 +84,15 @@ internal struct ThemeEditorView: View { } private func duplicateAndSelect() { - let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)") + var copy = theme + copy.id = ThemeIdentifier.generated() + copy.name = String(format: String(localized: "%@ (Copy)"), theme.name) + do { - try engine.saveUserTheme(copy) - engine.activateTheme(copy) + try catalog.save(copy) selectedThemeId = copy.id } catch { + Self.logger.error("Could not duplicate theme: \(error.localizedDescription)") errorMessage = error.localizedDescription showError = true } diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index 4a943deef7..86ac15be46 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -1,65 +1,60 @@ import AppKit +import os import SwiftUI import UniformTypeIdentifiers +/// Every action here acts on the theme the slot has selected. Acting on the active theme instead +/// let a delete in the dark slot write the light theme's id into it, so at dusk the editor turned +/// white inside dark chrome. internal struct ThemeListView: View { - @Binding var selectedThemeId: String + @Binding internal var selectedThemeId: String internal var slotAppearance: ThemeAppearance = .light - private var engine: ThemeEngine { ThemeEngine.shared } - @State private var showDeleteConfirmation = false @State private var errorMessage: String? @State private var showError = false + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeListView") + + private var catalog: ThemeCatalog { ThemeCatalog.shared } + private var builtInThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes( - engine.availableThemes.filter(\.isBuiltIn), - slot: slotAppearance, - keeping: selectedThemeId - ) + eligible(catalog.themes.filter(\.isBuiltIn)) } private var registryThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes( - engine.registryThemes, - slot: slotAppearance, - keeping: selectedThemeId - ) + eligible(catalog.themes.filter(\.isRegistry)) } private var customThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes( - engine.availableThemes.filter(\.isEditable), - slot: slotAppearance, - keeping: selectedThemeId - ) + eligible(catalog.themes.filter(\.isEditable)) } private var selectedTheme: ThemeDefinition? { - engine.availableThemes.first { $0.id == selectedThemeId } + catalog.theme(id: selectedThemeId) + } + + private var fallbackThemeId: String { + BuiltInThemes.defaultId(for: slotAppearance) } - private var isDeleteDisabled: Bool { - guard let theme = selectedTheme else { return true } - return !theme.isEditable + private func eligible(_ themes: [ThemeDefinition]) -> [ThemeDefinition] { + ThemeSlotValidation.eligibleThemes(themes, slot: slotAppearance, keeping: selectedThemeId) } - var body: some View { + internal var body: some View { VStack(spacing: 0) { List(selection: $selectedThemeId) { Section("Built-in") { ForEach(builtInThemes) { theme in - ThemeListRowView(theme: theme) - .tag(theme.id) + ThemeListRowView(theme: theme).tag(theme.id) } } if !registryThemes.isEmpty { Section("Registry") { ForEach(registryThemes) { theme in - ThemeListRowView(theme: theme) - .tag(theme.id) + ThemeListRowView(theme: theme).tag(theme.id) } } } @@ -67,8 +62,15 @@ internal struct ThemeListView: View { if !customThemes.isEmpty { Section("Custom") { ForEach(customThemes) { theme in - ThemeListRowView(theme: theme) - .tag(theme.id) + ThemeListRowView(theme: theme).tag(theme.id) + } + } + } + + if !catalog.rejected.isEmpty { + Section("Not Loaded") { + ForEach(catalog.rejected, id: \.path) { record in + rejectedRow(record) } } } @@ -78,72 +80,16 @@ internal struct ThemeListView: View { Divider() - HStack(spacing: 4) { - Menu { - Button(String(localized: "New Theme")) { - duplicateActiveTheme() - } - Divider() - Button(String(localized: "Import…")) { - importTheme() - } - } label: { - Image(systemName: "plus") - .frame(width: 24, height: 24) - } - .menuIndicator(.hidden) - .buttonStyle(.borderless) - .frame(width: 28) - .help(Text("Add Theme")) - .accessibilityLabel(Text("Add Theme")) - - Button { - showDeleteConfirmation = true - } label: { - Image(systemName: "minus") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .disabled(isDeleteDisabled) - .help(Text("Delete Theme")) - .accessibilityLabel(Text("Delete Theme")) - - Menu { - Button(String(localized: "Duplicate")) { - duplicateActiveTheme() - } - Button(String(localized: "Export…")) { - exportActiveTheme() - } - if selectedTheme?.isRegistry == true { - Divider() - Button(String(localized: "Uninstall"), role: .destructive) { - uninstallRegistryTheme() - } - } - } label: { - Image(systemName: "gearshape") - .frame(width: 24, height: 24) - } - .menuIndicator(.hidden) - .buttonStyle(.borderless) - .frame(width: 28) - .help(Text("Theme Actions")) - .accessibilityLabel(Text("Theme Actions")) - - Spacer() - } - .padding(.horizontal, 8) - .padding(.vertical, 4) + toolbar } .alert(String(localized: "Delete Theme"), isPresented: $showDeleteConfirmation) { - Button(String(localized: "Delete"), role: .destructive) { - deleteSelectedTheme() - } + Button(String(localized: "Delete"), role: .destructive) { deleteSelectedTheme() } Button(String(localized: "Cancel"), role: .cancel) {} } message: { - let name = engine.availableThemes.first(where: { $0.id == selectedThemeId })?.name ?? "" - Text(String(format: String(localized: "Are you sure you want to delete \"%@\"?"), name)) + Text(String( + format: String(localized: "Are you sure you want to delete \"%@\"?"), + selectedTheme?.name ?? "" + )) } .alert(String(localized: "Error"), isPresented: $showError) { Button(String(localized: "OK")) {} @@ -154,53 +100,108 @@ internal struct ThemeListView: View { } } - // MARK: - Actions + /// A file the loader refused is shown with its reason rather than dropped in silence, which is + /// what the previous loader did for a malformed theme. + private func rejectedRow(_ record: RejectedThemeRecord) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(record.fileName) + .font(.callout) + .lineLimit(1) + + Text(record.reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + .help(Text(record.reason)) + } - private func duplicateActiveTheme() { - let theme = engine.activeTheme - let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)") - do { - try engine.saveUserTheme(copy) - selectedThemeId = copy.id - } catch { - errorMessage = error.localizedDescription - showError = true + private var toolbar: some View { + HStack(spacing: 4) { + Menu { + Button(String(localized: "Duplicate Selected")) { duplicateSelectedTheme() } + Divider() + Button(String(localized: "Import…")) { importTheme() } + } label: { + Image(systemName: "plus").frame(width: 24, height: 24) + } + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .frame(width: 28) + .help(Text("Add Theme")) + .accessibilityLabel(Text("Add Theme")) + + Button { + showDeleteConfirmation = true + } label: { + Image(systemName: "minus").frame(width: 24, height: 24) + } + .buttonStyle(.borderless) + .disabled(selectedTheme?.isEditable != true) + .help(Text("Delete Theme")) + .accessibilityLabel(Text("Delete Theme")) + + Menu { + Button(String(localized: "Duplicate")) { duplicateSelectedTheme() } + Button(String(localized: "Export…")) { exportSelectedTheme() } + if selectedTheme?.isRegistry == true { + Divider() + Button(String(localized: "Uninstall"), role: .destructive) { uninstallRegistryTheme() } + } + } label: { + Image(systemName: "gearshape").frame(width: 24, height: 24) + } + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .frame(width: 28) + .help(Text("Theme Actions")) + .accessibilityLabel(Text("Theme Actions")) + + Spacer() } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + // MARK: - Actions + + private func duplicateSelectedTheme() { + guard let theme = selectedTheme else { return } + var copy = theme + copy.id = ThemeIdentifier.generated() + copy.name = String(format: String(localized: "%@ (Copy)"), theme.name) + + perform { try catalog.save(copy) } then: { selectedThemeId = copy.id } } private func deleteSelectedTheme() { - do { - try engine.deleteUserTheme(id: selectedThemeId) - selectedThemeId = engine.activeTheme.id - } catch { - errorMessage = error.localizedDescription - showError = true - } + guard let theme = selectedTheme, theme.isEditable else { return } + perform { try catalog.delete(id: theme.id) } then: { selectedThemeId = fallbackThemeId } } private func uninstallRegistryTheme() { guard let theme = selectedTheme, theme.isRegistry else { return } - let meta = ThemeStorage.loadRegistryMeta() - guard let entry = meta.installed.first(where: { $0.id == theme.id }) else { return } - do { - try engine.uninstallRegistryTheme(registryPluginId: entry.registryPluginId) - selectedThemeId = engine.activeTheme.id - } catch { - errorMessage = error.localizedDescription - showError = true + guard let entry = catalog.loadRegistryMeta().installed.first(where: { $0.id == theme.id }) else { return } + + perform { + try ThemeRegistryInstaller.shared.uninstall(registryPluginId: entry.registryPluginId) + } then: { + selectedThemeId = fallbackThemeId } } - private func exportActiveTheme() { - guard let window = AlertHelper.resolveWindow(nil) else { return } + private func exportSelectedTheme() { + guard let theme = selectedTheme, let window = AlertHelper.resolveWindow(nil) else { return } + let panel = NSSavePanel() panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = engine.activeTheme.name + ".json" + panel.nameFieldStringValue = theme.name + ".json" panel.canCreateDirectories = true panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } do { - try engine.exportTheme(engine.activeTheme, to: url) + try catalog.exportTheme(theme, to: url) } catch { AlertHelper.showErrorSheet( title: String(localized: "Could not export the theme"), @@ -213,19 +214,29 @@ internal struct ThemeListView: View { private func importTheme() { guard let window = AlertHelper.resolveWindow(nil) else { return } + let panel = NSOpenPanel() panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } - do { - let imported = try self.engine.importTheme(from: url) - self.selectedThemeId = imported.id - } catch { - self.errorMessage = error.localizedDescription - self.showError = true + perform { + let imported = try catalog.importTheme(from: url) + guard imported.appearance == slotAppearance else { return } + selectedThemeId = imported.id } } } + + private func perform(_ work: () throws -> Void, then completion: () -> Void = {}) { + do { + try work() + completion() + } catch { + Self.logger.error("Theme action failed: \(error.localizedDescription)") + errorMessage = error.localizedDescription + showError = true + } + } } diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index a273c883e5..b473035976 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -83,7 +83,7 @@ struct AppearanceSettingsView: View { ThemeListView(selectedThemeId: slotThemeBinding, slotAppearance: slotAppearance) .frame(minWidth: 180, idealWidth: 210, maxWidth: 250) - ThemeEditorView(selectedThemeId: slotThemeBinding) + ThemeEditorView(selectedThemeId: slotThemeBinding, slotAppearance: slotAppearance) .frame(minWidth: 400) } } diff --git a/TablePro/Views/Settings/DataResultsSettingsView.swift b/TablePro/Views/Settings/DataResultsSettingsView.swift index a0a72fea87..af0554b2d0 100644 --- a/TablePro/Views/Settings/DataResultsSettingsView.swift +++ b/TablePro/Views/Settings/DataResultsSettingsView.swift @@ -9,9 +9,12 @@ struct DataResultsSettingsView: View { @Binding var dataGrid: DataGridSettings @Binding var history: HistorySettings @Binding var editor: EditorSettings + @Binding var typography: TypographySettings var body: some View { Form { + TypographySection(domain: .dataGrid, settings: $typography) + DataGridSection(settings: $dataGrid) Section("JSON Viewer") { @@ -34,7 +37,8 @@ struct DataResultsSettingsView: View { DataResultsSettingsView( dataGrid: .constant(.default), history: .constant(.default), - editor: .constant(.default) + editor: .constant(.default), + typography: .constant(.default) ) .frame(width: 450, height: 500) } diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index f59e3bba5f..7faf144016 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -7,9 +7,12 @@ import SwiftUI struct EditorSettingsView: View { @Binding var settings: EditorSettings + @Binding var typography: TypographySettings var body: some View { Form { + TypographySection(domain: .editor, settings: $typography) + Section("SQL Editor") { Toggle("Show line numbers", isOn: $settings.showLineNumbers) Toggle("Highlight current line", isOn: $settings.highlightCurrentLine) @@ -17,6 +20,8 @@ struct EditorSettingsView: View { Toggle("Word wrap", isOn: $settings.wordWrap) Toggle("Code folding", isOn: $settings.codeFoldingEnabled) Toggle("Run button beside each statement", isOn: $settings.showStatementRunControls) + .disabled(!settings.showLineNumbers) + .help(Text("The run button sits in the gutter, which needs line numbers.")) Toggle("Show invisible characters", isOn: $settings.showInvisibleCharacters) Picker("Tab width:", selection: $settings.tabWidth) { Text("2 spaces").tag(2) @@ -35,6 +40,6 @@ struct EditorSettingsView: View { } #Preview { - EditorSettingsView(settings: .constant(.default)) + EditorSettingsView(settings: .constant(.default), typography: .constant(.default)) .frame(width: 450, height: 500) } diff --git a/TablePro/Views/Settings/Sections/TypographySection.swift b/TablePro/Views/Settings/Sections/TypographySection.swift new file mode 100644 index 0000000000..cb50b3a815 --- /dev/null +++ b/TablePro/Views/Settings/Sections/TypographySection.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// The two font domains, each on the pane that owns the surfaces it applies to. They used to live +/// inside the theme file, so a zoom shortcut rewrote a whole theme, and a font edit on a built-in +/// forked it into a copy. +internal struct TypographySection: View { + internal enum Domain { + case editor + case dataGrid + + internal var title: String { + switch self { + case .editor: return String(localized: "Editor Font") + case .dataGrid: return String(localized: "Data Grid Font") + } + } + + internal var caption: String { + switch self { + case .editor: + return String(localized: "Applies to the SQL editor, the JSON viewer's Text mode, and the previews.") + case .dataGrid: + return String(localized: "Applies to grid cells, the inspector, cell popovers and the row diff.") + } + } + } + + internal let domain: Domain + @Binding internal var settings: TypographySettings + + internal var body: some View { + Section(domain.title) { + Picker(String(localized: "Family:"), selection: familyBinding) { + ForEach(EditorFontResolver.availableMonospacedFamilies) { family in + Text(family.displayName).tag(family.id) + } + } + + Picker(String(localized: "Size:"), selection: sizeBinding) { + ForEach(TypographySettings.sizeRange, id: \.self) { size in + Text(verbatim: "\(size) pt").tag(size) + } + } + + Text(domain.caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var familyBinding: Binding { + switch domain { + case .editor: return $settings.editorFontFamily + case .dataGrid: return $settings.dataGridFontFamily + } + } + + private var sizeBinding: Binding { + switch domain { + case .editor: return $settings.editorFontSize + case .dataGrid: return $settings.dataGridFontSize + } + } +} diff --git a/TablePro/Views/Settings/SettingsWindowController.swift b/TablePro/Views/Settings/SettingsWindowController.swift index ae7f2e75e2..0d86a2a587 100644 --- a/TablePro/Views/Settings/SettingsWindowController.swift +++ b/TablePro/Views/Settings/SettingsWindowController.swift @@ -140,12 +140,16 @@ private struct SettingsPaneContent: View { case .appearance: AppearanceSettingsView(settings: $settingsManager.appearance) case .editor: - EditorSettingsView(settings: $settingsManager.editor) + EditorSettingsView( + settings: $settingsManager.editor, + typography: $settingsManager.typography + ) case .data: DataResultsSettingsView( dataGrid: $settingsManager.dataGrid, history: $settingsManager.history, - editor: $settingsManager.editor + editor: $settingsManager.editor, + typography: $settingsManager.typography ) case .keyboard: KeyboardSettingsView(settings: $settingsManager.keyboard) diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift index 3302f4e1bf..c43c1147d7 100644 --- a/TablePro/Views/Settings/ThemePreviewCard.swift +++ b/TablePro/Views/Settings/ThemePreviewCard.swift @@ -18,6 +18,8 @@ struct ThemePreviewCard: View { let onSelect: () -> Void var size: CardSize = .standard + @Environment(\.colorScheme) private var colorScheme + var body: some View { switch size { case .standard: @@ -99,29 +101,33 @@ struct ThemePreviewCard: View { dataGridArea } } + .environment(\.colorScheme, previewColorScheme) + } + + /// The system colours a theme leaves undeclared resolve in the appearance the theme is used + /// in, not in whichever one Settings happens to be showing. + private var previewColorScheme: ColorScheme { + switch theme.appearance { + case .dark: return .dark + case .light: return .light + } } private var sidebarStrip: some View { ZStack(alignment: .topLeading) { Rectangle() - .fill(theme.sidebar.background?.swiftUIColor - ?? Color(nsColor: .windowBackgroundColor)) + .fill(theme.dataGrid.headerBackground.swiftUIColor) VStack(alignment: .leading, spacing: size == .compact ? 3 : 4) { let widths: [CGFloat] = size == .compact ? [10, 14, 13, 9] : [14, 18, 17, 12] - ForEach(0..<4, id: \.self) { i in + ForEach(0..<4, id: \.self) { index in RoundedRectangle(cornerRadius: 1) - .fill(i == 1 - ? (theme.sidebar.selectedItem?.swiftUIColor - ?? Color(nsColor: .selectedContentBackgroundColor)).opacity(0.6) - : (theme.sidebar.text?.swiftUIColor - ?? Color(nsColor: .labelColor)).opacity(0.25)) - .frame( - width: widths[i], - height: codeLineHeight - ) + .fill(index == 1 + ? theme.dataGrid.selection.swiftUIColor.opacity(0.6) + : theme.dataGrid.rowNumber.swiftUIColor.opacity(0.6)) + .frame(width: widths[index], height: codeLineHeight) } } .padding(.top, size == .compact ? 5 : 8) @@ -158,7 +164,7 @@ struct ThemePreviewCard: View { } } - private func codeLine(widths: [CGFloat], colors: [String]) -> some View { + private func codeLine(widths: [CGFloat], colors: [ThemeColorValue]) -> some View { HStack(spacing: size == .compact ? 2 : 3) { ForEach(Array(zip(widths, colors).enumerated()), id: \.offset) { _, pair in RoundedRectangle(cornerRadius: 1) @@ -169,22 +175,22 @@ struct ThemePreviewCard: View { } private var dataGridArea: some View { - VStack(spacing: 0) { + let colors = theme.dataGrid + return VStack(spacing: 0) { ForEach(0.. Void + let operation: PluginMaintenanceOperation + let tableName: String? + let databaseName: String? + /// Statements for the given option values. Synchronous and pure, so calling it for every toggle + /// while `body` runs costs an array of strings. + let preview: ([String: String]) -> [String] + let onExecute: ([String: String]) -> Void + + @State private var values: [String: String] - @State private var fullVacuum = false - @State private var analyzeAfterVacuum = false - @State private var verbose = false - @State private var checkMode = "MEDIUM" + init( + operation: PluginMaintenanceOperation, + tableName: String?, + databaseName: String?, + preview: @escaping ([String: String]) -> [String], + onExecute: @escaping ([String: String]) -> Void + ) { + self.operation = operation + self.tableName = tableName + self.databaseName = databaseName + self.preview = preview + self.onExecute = onExecute + _values = State(initialValue: operation.defaultOptionValues) + } var body: some View { VStack(alignment: .leading, spacing: 16) { - HStack { - Image(systemName: "wrench.and.screwdriver") - .font(.title2) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(operation) - .font(.headline) - Text(tableName) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - } + header Divider() - operationOptions + if !operation.options.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(operation.options, id: \.key) { option in + optionControl(option) + } + } + } VStack(alignment: .leading, spacing: 4) { Text(String(localized: "SQL Preview")) @@ -47,6 +61,7 @@ struct MaintenanceSheet: View { .foregroundStyle(.secondary) Text(sqlPreview) .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(nsColor: .textBackgroundColor)) @@ -60,66 +75,72 @@ struct MaintenanceSheet: View { Button(String(localized: "Cancel")) { dismiss() } .keyboardShortcut(.cancelAction) Button(String(localized: "Execute")) { - onExecute(operation, tableName, buildOptions()) + onExecute(values) dismiss() } .keyboardShortcut(.defaultAction) .buttonStyle(.borderedProminent) + .disabled(sqlPreview.isEmpty) } } .padding(20) .frame(width: 420) } - // MARK: - Options + private var header: some View { + HStack { + Image(systemName: "wrench.and.screwdriver") + .font(.title2) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(operation.name) + .font(.headline) + if let subject { + Text(subject) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + } + } + + /// What the statement acts on. An operation that names no object reads as the database it runs + /// against, rather than as the table the row it was reached from happened to be. + private var subject: String? { + operation.target(tableName)?.nilIfEmpty ?? databaseName?.nilIfEmpty + } @ViewBuilder - private var operationOptions: some View { - switch operation { - case "VACUUM" where databaseType == .postgresql || databaseType == .redshift: - Toggle(String(localized: "FULL (rewrites entire table, blocks access)"), isOn: $fullVacuum) - Toggle(String(localized: "ANALYZE (update statistics after vacuum)"), isOn: $analyzeAfterVacuum) - Toggle(String(localized: "VERBOSE (print progress)"), isOn: $verbose) - case "CHECK TABLE": - Picker(String(localized: "Check mode:"), selection: $checkMode) { - Text("QUICK").tag("QUICK") - Text("FAST").tag("FAST") - Text("MEDIUM").tag("MEDIUM") - Text("EXTENDED").tag("EXTENDED") - Text("CHANGED").tag("CHANGED") + private func optionControl(_ option: PluginMaintenanceOption) -> some View { + if let choices = option.choices { + Picker(option.label, selection: binding(for: option)) { + ForEach(choices, id: \.self) { choice in + Text(choice).tag(choice) + } } .pickerStyle(.menu) .frame(width: 200) - default: - EmptyView() + } else { + Toggle(option.label, isOn: toggleBinding(for: option)) } } - // MARK: - SQL Preview + private func binding(for option: PluginMaintenanceOption) -> Binding { + Binding( + get: { values[option.key] ?? option.defaultValue }, + set: { values[option.key] = $0 } + ) + } - private var sqlPreview: String { - let options = buildOptions() - switch operation { - case "VACUUM" where databaseType == .postgresql || databaseType == .redshift: - var opts: [String] = [] - if options["full"] == "true" { opts.append("FULL") } - if options["analyze"] == "true" { opts.append("ANALYZE") } - if options["verbose"] == "true" { opts.append("VERBOSE") } - let optClause = opts.isEmpty ? "" : "(\(opts.joined(separator: ", "))) " - return "VACUUM \(optClause)\(tableName)" - case "CHECK TABLE": - return "CHECK TABLE \(tableName) \(checkMode)" - default: - return "\(operation) \(tableName)" - } + private func toggleBinding(for option: PluginMaintenanceOption) -> Binding { + Binding( + get: { (values[option.key] ?? option.defaultValue) == "true" }, + set: { values[option.key] = $0 ? "true" : "false" } + ) } - private func buildOptions() -> [String: String] { - var options: [String: String] = [:] - if fullVacuum { options["full"] = "true" } - if analyzeAfterVacuum { options["analyze"] = "true" } - if verbose { options["verbose"] = "true" } - if operation == "CHECK TABLE" { options["mode"] = checkMode } - return options + private var sqlPreview: String { + preview(values).joined(separator: ";\n") } } diff --git a/TablePro/Views/Sidebar/MaterializedViewRefreshAlert.swift b/TablePro/Views/Sidebar/MaterializedViewRefreshAlert.swift new file mode 100644 index 0000000000..d23fa10e93 --- /dev/null +++ b/TablePro/Views/Sidebar/MaterializedViewRefreshAlert.swift @@ -0,0 +1,80 @@ +// +// MaterializedViewRefreshAlert.swift +// TablePro +// + +import AppKit + +/// An informational alert, not a destructive one: the user chose this command on purpose and a +/// refresh loses nothing, so the confirming button keeps Return, which the HIG reserves taking away +/// for an action people did not deliberately choose. +@MainActor +internal enum MaterializedViewRefreshAlert { + private static let accessoryWidth: CGFloat = 300 + private static let descriptionIndent: CGFloat = 20 + + /// - Parameter completion: nil when cancelled, otherwise whether to refresh concurrently. + internal static func present( + prompt: MaterializedViewRefreshPrompt, + window: NSWindow?, + completion: @escaping @MainActor (Bool?) -> Void + ) { + let alert = NSAlert() + alert.messageText = prompt.messageText + alert.informativeText = prompt.informativeText + alert.alertStyle = .informational + alert.addButton(withTitle: prompt.confirmButtonTitle) + AlertHelper.addCancelButton(to: alert, title: prompt.cancelButtonTitle) + + let checkbox = prompt.showsConcurrentOption ? concurrentCheckbox(prompt: prompt) : nil + if let checkbox { + alert.accessoryView = accessoryView(checkbox: checkbox, description: prompt.concurrentOptionDescription) + alert.layout() + } + + AlertHelper.present(alert, in: window) { response in + guard response == .alertFirstButtonReturn else { + completion(nil) + return + } + completion(prompt.refreshesConcurrently(checkboxIsOn: checkbox?.state == .on)) + } + } + + private static func concurrentCheckbox(prompt: MaterializedViewRefreshPrompt) -> NSButton { + let button = NSButton(checkboxWithTitle: prompt.concurrentOptionTitle, target: nil, action: nil) + button.state = .off + button.isEnabled = prompt.isConcurrentOptionEnabled + button.setAccessibilityIdentifier("refresh-materialized-view-concurrently") + if !prompt.isConcurrentOptionEnabled { + button.toolTip = prompt.concurrentOptionDescription + button.setAccessibilityHelp(prompt.concurrentOptionDescription) + } + return button + } + + private static func accessoryView(checkbox: NSButton, description: String) -> NSView { + let label = NSTextField(wrappingLabelWithString: description) + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + label.textColor = .secondaryLabelColor + label.preferredMaxLayoutWidth = accessoryWidth - descriptionIndent + + let indented = NSStackView(views: [label]) + indented.orientation = .horizontal + indented.alignment = .top + indented.edgeInsets = NSEdgeInsets(top: 0, left: descriptionIndent, bottom: 0, right: 0) + + let rows: [NSView] = [checkbox, indented] + let stack = NSStackView(views: rows) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + for row in rows { + row.widthAnchor.constraint(equalToConstant: accessoryWidth).isActive = true + } + stack.layoutSubtreeIfNeeded() + stack.frame = NSRect(origin: .zero, size: stack.fittingSize) + return stack + } +} diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift index 274bf70692..eed6bab57e 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift @@ -23,7 +23,8 @@ internal struct DatabaseTreeMenuContext { internal let isReadOnly: Bool internal let supportsImport: Bool internal let importFormats: [ImportFormatOption] - internal let maintenanceOperations: [String] + /// Everything the driver offers. The spec narrows it to the clicked row's own kind. + internal let maintenanceOperations: [PluginMaintenanceOperation] internal let dropEligibility: ContainerDropEligibility.Context internal let renameEligibility: ObjectRenameEligibility.Context internal let containerEntityName: String @@ -51,4 +52,6 @@ internal struct DatabaseTreeMenuContext { internal var canBackUp: Bool = false /// Whether the driver can offer a CREATE TYPE template. Read-only mode still hides the item. internal var canCreateType: Bool = false + /// Which materialized-view and comment commands the driver has statements for. + internal var objectToolSupport: DatabaseObjectToolEligibility.Support = .none } diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 2a2275be8e..3265e8e301 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -91,27 +91,38 @@ internal enum DatabaseTreeMenuSpec { .command(String(localized: "Open in New Tab"), .openInNewTab(ref)), .command(String(localized: "Show Structure"), .showStructure(ref)) ] + if let objectRef = sourceObjectRef(for: ref) { + items.append(.command(String(localized: "Show DDL"), .showObjectSource(objectRef))) + } if SidebarContextMenuLogic.isView(clickedTable: ref.table), !context.isReadOnly { items.append(.command(String(localized: "Edit View Definition"), .editViewDefinition(ref))) } return items } + /// A view's source opens in the read-only DDL viewer, addressed by the row's own database and + /// schema so it reads the view the user clicked rather than a same-named one where the browser is. + private static func sourceObjectRef(for ref: DatabaseTreeTableRef) -> DatabaseObjectRef? { + DatabaseObjectRef(relation: ref.table, database: ref.database ?? "", schema: ref.qualifyingSchema) + } + private static func noteItems( _ ref: DatabaseTreeTableRef, targets: [DatabaseTreeTableRef], context: DatabaseTreeMenuContext ) -> [DatabaseTreeMenuItem] { let names = targets.map(\.table.name).sorted() - return [ - .command(copyNamesTitle(count: names.count), .copyTableNames(names)), - .command( - context.isFavorite - ? String(localized: "Remove from Favorites") - : String(localized: "Add to Favorites"), - .toggleFavorite(ref) - ) - ] + var items: [DatabaseTreeMenuItem] = [.command(copyNamesTitle(count: names.count), .copyTableNames(names))] + if DatabaseObjectToolEligibility.canShowDDL(ref.table.type) { + items.append(.command(String(localized: "Copy DDL"), .copyDDL(ref))) + } + items.append(.command( + context.isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites"), + .toggleFavorite(ref) + )) + return items } /// Everything that moves the object's data somewhere else, in the order the work usually runs: @@ -140,15 +151,30 @@ internal enum DatabaseTreeMenuSpec { .copyObjectsTo(objects: copySelections(for: sameScope), ref: ref) )) } + /// Beside Maintenance, the other command that runs a server operation on the object's data. + if DatabaseObjectToolEligibility.canRefresh( + ref.table.type, + support: context.objectToolSupport, + isReadOnly: context.isReadOnly + ) { + items.append(.command(String(localized: "Refresh Materialized View…"), .refreshMaterializedView(ref))) + } + let applicable = TableOperationEligibility.maintenanceOperations( + context.maintenanceOperations, + for: ref.table.type + ) if SidebarContextMenuLogic.maintenanceGroupEnabled( isReadOnly: context.isReadOnly, hasSelection: true, - supportedOperations: context.maintenanceOperations + applicableOperations: applicable ) { items.append(.submenu( title: String(localized: "Maintenance"), - items: context.maintenanceOperations.map { operation in - .command(operation, .maintenance(operation: operation, tableName: ref.table.name, ref: ref)) + items: applicable.map { operation in + .command( + operation.name, + .maintenance(operation: operation, tableName: ref.table.name, ref: ref) + ) } )) } @@ -165,6 +191,13 @@ internal enum DatabaseTreeMenuSpec { ) -> [DatabaseTreeMenuItem] { guard !context.isReadOnly else { return [] } var items: [DatabaseTreeMenuItem] = [] + if DatabaseObjectToolEligibility.canEditComment( + ref.table.type, + support: context.objectToolSupport, + isReadOnly: context.isReadOnly + ) { + items.append(.command(String(localized: "Edit Comment…"), .editComment(ref))) + } if ObjectRenameEligibility.canRename(table: ref.table, context: context.renameEligibility) { items.append(.command(String(localized: "Rename"), .beginRenameTable(ref: ref, isRecentRow: isRecentRow))) } diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 077f337998..6821cf80b8 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -23,6 +23,9 @@ internal enum SidebarMenuCommand: Equatable { case showAllDatabases case openInNewTab(DatabaseTreeTableRef) case editViewDefinition(DatabaseTreeTableRef) + case copyDDL(DatabaseTreeTableRef) + case refreshMaterializedView(DatabaseTreeTableRef) + case editComment(DatabaseTreeTableRef) case showStructure(DatabaseTreeTableRef) case showERDiagram case copyTableNames([String]) @@ -33,7 +36,9 @@ internal enum SidebarMenuCommand: Equatable { case exportTables(names: Set, ref: DatabaseTreeTableRef) case transferTables(names: Set, ref: DatabaseTreeTableRef) case importTables(formatId: String, ref: DatabaseTreeTableRef) - case maintenance(operation: String, tableName: String, ref: DatabaseTreeTableRef) + /// Carries the whole descriptor rather than the operation's name, because the sheet it opens needs + /// the scope and the options to build the statement it shows and the one it runs. + case maintenance(operation: PluginMaintenanceOperation, tableName: String, ref: DatabaseTreeTableRef) /// Queued rather than run, so these carry every target in full: a queue keyed by name is /// resolved against whatever the tab in front points at by the time Save runs. case truncateTables(targets: [DatabaseTreeTableRef], ref: DatabaseTreeTableRef) diff --git a/TablePro/Views/Sidebar/ObjectCommentSheet.swift b/TablePro/Views/Sidebar/ObjectCommentSheet.swift new file mode 100644 index 0000000000..3e1c20ecce --- /dev/null +++ b/TablePro/Views/Sidebar/ObjectCommentSheet.swift @@ -0,0 +1,157 @@ +// +// ObjectCommentSheet.swift +// TablePro +// + +import os +import SwiftUI + +/// Edits the comment on one table, view, materialized view or foreign table. +/// +/// A sheet with an explicit Save rather than a field that commits on its own: the write is a +/// statement against the server, and a popover or an inspector field that saves when it loses +/// focus would run it on a stray click. +struct ObjectCommentSheet: View { + private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCommentSheet") + + private enum Phase: Equatable { + case loading + case editing + case saving + case loadFailed(String) + } + + @Environment(\.dismiss) private var dismiss + + let target: DatabaseObjectTarget + let connection: DatabaseConnection + + @State private var draft = ObjectCommentDraft(original: nil) + @State private var phase: Phase = .loading + @State private var saveError: String? + @FocusState private var isEditorFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + Divider() + buttonBar + } + .frame(width: 480, height: 320) + .task { await load() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Edit Comment") + .font(.headline) + Text(target.qualifiedName) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + @ViewBuilder + private var content: some View { + switch phase { + case .loading: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loadFailed(let message): + ContentUnavailableView { + Label("Comment Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Try Again") { + Task { await load() } + } + } + case .editing, .saving: + editor + } + } + + private var editor: some View { + VStack(alignment: .leading, spacing: 8) { + TextEditor(text: $draft.text) + .font(ThemeEngine.shared.valueFontSwiftUI) + .focused($isEditorFocused) + .disabled(phase == .saving) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor)) + ) + .accessibilityLabel(String(localized: "Comment")) + .accessibilityIdentifier("object-comment-editor") + if let saveError { + Text(saveError) + .font(.callout) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } else { + Text("Leave the comment empty to remove it.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var buttonBar: some View { + HStack { + if phase == .saving { + ProgressView() + .controlSize(.small) + } + Spacer() + Button(String(localized: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + Button(draft.removesComment ? String(localized: "Remove Comment") : String(localized: "Save")) { + Task { await save() } + } + .keyboardShortcut(.defaultAction) + .disabled(phase != .editing || !draft.hasChanges) + .accessibilityIdentifier("object-comment-save") + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private func load() async { + phase = .loading + do { + let comment = try await ObjectCommentEditing.currentComment(of: target) + draft = ObjectCommentDraft(original: comment) + phase = .editing + isEditorFocused = true + } catch { + Self.logger.error("Failed to read comment: \(error.localizedDescription, privacy: .public)") + phase = .loadFailed(error.localizedDescription) + } + } + + private func save() async { + phase = .saving + saveError = nil + do { + try await ObjectCommentEditing.setComment(draft.commentToSave, on: target, connection: connection) + dismiss() + } catch { + saveError = error.localizedDescription + phase = .editing + } + } +} diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 7b403237ec..49913bc207 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -38,12 +38,15 @@ enum SidebarContextMenuLogic { } } + /// Asked with the operations that apply to the clicked object, not with everything the driver + /// offers: on a view PostgreSQL has four operations and none of them works, so the submenu is + /// omitted rather than filled with commands that skip or fail. static func maintenanceGroupEnabled( isReadOnly: Bool, hasSelection: Bool, - supportedOperations: [String] + applicableOperations: [PluginMaintenanceOperation] ) -> Bool { guard !isReadOnly, hasSelection else { return false } - return !supportedOperations.isEmpty + return !applicableOperations.isEmpty } } diff --git a/TablePro/Views/Structure/ClickHousePartsView.swift b/TablePro/Views/Structure/ClickHousePartsView.swift index 18874f9dbb..7b50340c39 100644 --- a/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/TablePro/Views/Structure/ClickHousePartsView.swift @@ -30,7 +30,7 @@ struct ClickHousePartsView: View { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .accessibilityHidden(true) RevealedTextView(error) .foregroundStyle(.secondary) @@ -99,7 +99,7 @@ struct ClickHousePartsView: View { .width(min: 100, ideal: 160) TableColumn("Active") { part in Image(systemName: part.active ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(part.active ? .green : .secondary) + .foregroundStyle(part.active ? ThemeEngine.shared.palette.color(.statusSuccess) : .secondary) } .width(min: 50, ideal: 60) } diff --git a/TablePro/Views/Structure/CreateTableGridDelegate+Inspector.swift b/TablePro/Views/Structure/CreateTableGridDelegate+Inspector.swift index 47d4900105..bb238635e0 100644 --- a/TablePro/Views/Structure/CreateTableGridDelegate+Inspector.swift +++ b/TablePro/Views/Structure/CreateTableGridDelegate+Inspector.swift @@ -18,7 +18,8 @@ extension CreateTableGridDelegate: InspectorRowSource { changeManager: structureChangeManager, tab: structureTab, databaseType: connection.type, - additionalFields: [.primaryKey] + additionalFields: [.primaryKey], + serverSupport: serverSupport ), canEditSchema: true, rowOptions: { dataGridMenuOptions(forRow: displayRow, columnIndex: $0) } diff --git a/TablePro/Views/Structure/CreateTableGridDelegate.swift b/TablePro/Views/Structure/CreateTableGridDelegate.swift index 0afab81d7b..57e04c9e7a 100644 --- a/TablePro/Views/Structure/CreateTableGridDelegate.swift +++ b/TablePro/Views/Structure/CreateTableGridDelegate.swift @@ -16,6 +16,7 @@ final class CreateTableGridDelegate: DataGridViewDelegate { let connection: DatabaseConnection var onSelectedRowsChanged: ((Set) -> Void)? var orderedFields: [StructureColumnField] = [] + var serverSupport = StructureServerSupport.unrestricted /// The lists behind the Foreign Keys grid's reference cells. Held per delegate, so the column /// cache dies with the tab rather than outliving every connection. let referenceMenus: ForeignKeyReferenceMenus diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 58b02b5266..b458e3ed4f 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -56,6 +56,7 @@ struct CreateTableView: View { @State private var selectedRows: Set = [] @State private var sortState = SortState() @State private var columnLayout = ColumnLayoutState() + @State private var serverSupport = StructureServerSupport.unrestricted init( connection: DatabaseConnection, @@ -91,6 +92,7 @@ struct CreateTableView: View { coordinator?.inspectorRowSource = gridDelegate gridDelegate.onSelectedRowsChanged = { self.selectedRows = $0 } gridDelegate.onReferenceListsChanged = { coordinator?.inspectorRowSourceRevision += 1 } + serverSupport = StructureServerSupport.forConnection(connection.id) updateGridDelegate() if structureChangeManager.workingColumns.isEmpty { structureChangeManager.addNewColumn() @@ -176,7 +178,7 @@ struct CreateTableView: View { Spacer() } .padding() - .background(Color(nsColor: .controlBackgroundColor)) + .background(ThemeEngine.shared.palette.color(.panelBackground)) .onChange(of: draft.tableOptions.charset) { _, newCharset in if let first = CreateTableOptions.collations[newCharset]?.first { draft.tableOptions.collation = first @@ -300,9 +302,11 @@ struct CreateTableView: View { changeManager: structureChangeManager, tab: structureTab, databaseType: connection.type, - additionalFields: [.primaryKey] + additionalFields: [.primaryKey], + serverSupport: serverSupport ) gridDelegate.structureTab = structureTab + gridDelegate.serverSupport = serverSupport gridDelegate.orderedFields = provider.orderedColumnFields gridDelegate.schemaName = coordinator?.toolbarState.currentSchema coordinator?.inspectorRowSourceRevision += 1 @@ -313,7 +317,8 @@ struct CreateTableView: View { changeManager: structureChangeManager, tab: structureTab, databaseType: connection.type, - additionalFields: [.primaryKey] + additionalFields: [.primaryKey], + serverSupport: serverSupport ) // Rebuild the row snapshot fresh on every call so cell edits made @@ -323,13 +328,15 @@ struct CreateTableView: View { let manager = structureChangeManager let tab = structureTab let dbType = connection.type + let support = serverSupport return DataGridView( tableRowsProvider: { StructureRowProvider( changeManager: manager, tab: tab, databaseType: dbType, - additionalFields: [.primaryKey] + additionalFields: [.primaryKey], + serverSupport: support ).asTableRows() }, changeManager: wrappedChangeManager, diff --git a/TablePro/Views/Structure/CustomValueContentView.swift b/TablePro/Views/Structure/CustomValueContentView.swift index e5eed728bb..91ef215f13 100644 --- a/TablePro/Views/Structure/CustomValueContentView.swift +++ b/TablePro/Views/Structure/CustomValueContentView.swift @@ -22,6 +22,10 @@ internal struct CustomValueContentView: View { /// The connected driver's own escaping, so a value is escaped the way the engine reads it. /// MySQL doubles backslashes as well as quotes, which the shared helper does not. internal let escapeStringLiteral: (String) -> String + /// What the engine puts in front of a string literal, `N` on SQL Server and nothing anywhere + /// else. A default written without it is a `varchar` literal, so a non-Unicode collation turns + /// every character outside its code page into `?` as it parses the `ALTER TABLE`. + internal let stringLiteralPrefix: String internal let onCommit: (String) -> Void internal let onDismiss: () -> Void @@ -32,15 +36,20 @@ internal struct CustomValueContentView: View { internal init( initialValue: String, escapeStringLiteral: @escaping (String) -> String, + stringLiteralPrefix: String = "", onCommit: @escaping (String) -> Void, onDismiss: @escaping () -> Void ) { self.initialValue = initialValue self.escapeStringLiteral = escapeStringLiteral + self.stringLiteralPrefix = stringLiteralPrefix self.onCommit = onCommit self.onDismiss = onDismiss - if let text = SQLStringLiteral.unquoted(initialValue), - initialValue == "'\(escapeStringLiteral(text))'" { + let quotedPart = stringLiteralPrefix.isEmpty || !initialValue.hasPrefix(stringLiteralPrefix) + ? initialValue + : String(initialValue.dropFirst(stringLiteralPrefix.count)) + if let text = SQLStringLiteral.unquoted(quotedPart), + initialValue == "\(stringLiteralPrefix)'\(escapeStringLiteral(text))'" { _mode = State(initialValue: .text) _text = State(initialValue: text) } else { @@ -97,7 +106,7 @@ internal struct CustomValueContentView: View { private var resolvedSQL: String { switch mode { - case .text: "'\(escapeStringLiteral(text))'" + case .text: "\(stringLiteralPrefix)'\(escapeStringLiteral(text))'" case .expression: text } } diff --git a/TablePro/Views/Structure/DDLTextView.swift b/TablePro/Views/Structure/DDLTextView.swift index 45d27296b2..858f8492e9 100644 --- a/TablePro/Views/Structure/DDLTextView.swift +++ b/TablePro/Views/Structure/DDLTextView.swift @@ -32,7 +32,7 @@ struct DDLTextView: View { var body: some View { if ddl.isEmpty { - Color(nsColor: .textBackgroundColor) + ThemeEngine.shared.palette.color(.editorBackground) } else { SourceEditor( $text, @@ -47,6 +47,9 @@ struct DDLTextView: View { .onChange(of: colorScheme) { editorConfiguration = Self.makeConfiguration(fontSize: fontSize) } + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration(fontSize: fontSize) + } .onChange(of: fontSize) { _, newSize in editorConfiguration = Self.makeConfiguration(fontSize: newSize) } diff --git a/TablePro/Views/Structure/StructureEditGate.swift b/TablePro/Views/Structure/StructureEditGate.swift new file mode 100644 index 0000000000..daea3e356a --- /dev/null +++ b/TablePro/Views/Structure/StructureEditGate.swift @@ -0,0 +1,112 @@ +// +// StructureEditGate.swift +// TablePro +// +// What the Structure tab may offer for one connection and one object. +// + +import Foundation +import TableProPluginKit + +/// The Structure tab's single answer to "may this edit be offered on this object". +/// +/// One value rather than a flag per call site, because the footer pair, the grid's own keyboard and +/// context-menu paths, the reorder drag and the per-column lock all have to agree. They did not: the +/// footer read the engine's capability flags, the grid delegate read them again in its own guards, +/// and only the foreign key and reorder policies asked about the object at all, as +/// `isTable: !isViewObject`, which cannot say that a materialized view takes `CREATE INDEX` and +/// refuses `SET DEFAULT`. (#2726) +@MainActor +struct StructureEditGate { + let databaseType: DatabaseType + let objectKind: TableInfo.TableType + + private var matrix: StructureObjectEditMatrix { + PluginManager.shared.structureEditMatrix(for: databaseType) + } + + private var canEditSchema: Bool { databaseType.supportsSchemaEditing } + + func allows(_ operation: StructureEditOperation) -> Bool { + resolve(operation).isAvailable + } + + func resolve(_ operation: StructureEditOperation) -> StructureEditAvailability { + /// The foreign key arm is answered by `ForeignKeyEditPolicy`, which also owns the `.alter` + /// versus `.rebuild` distinction the save itself reads, and which words an engine's refusal + /// as "cannot add or remove a table's foreign keys" rather than the sentence every other + /// constraint shares. Routing it through here rather than beside here is what keeps every + /// call site asking one object. + if operation == .addForeignKey || operation == .dropForeignKey { + return foreignKeyAvailability.structureEditAvailability + } + return StructureEditEligibility.resolve( + operation, + on: objectKind, + matrix: matrix, + engineAllows: engineSupports(operation), + engineName: databaseType.displayName, + canEditSchema: canEditSchema + ) + } + + var foreignKeyAvailability: ForeignKeyEditAvailability { + ForeignKeyEditPolicy.resolve( + support: PluginManager.shared.foreignKeyEditSupport(for: databaseType), + engineName: databaseType.displayName, + kindRefusal: kindRefusal(.addForeignKey), + canEditSchema: canEditSchema + ) + } + + /// Why the object's kind refuses the operation, ignoring the engine. Handed to + /// `ForeignKeyEditPolicy` and `ColumnReorderPolicy`, which own the engine half of the same + /// decision and would otherwise have to word a refusal about a kind they cannot see. + func kindRefusal(_ operation: StructureEditOperation) -> String? { + StructureEditEligibility.refusalReason(for: operation, on: objectKind, matrix: matrix) + } + + /// Every field of the Columns grid this object lets the user change. A view keeps Name, Default + /// and Comment editable while Nullable, Type and the rest lock, because that is what PostgreSQL + /// accepts on one. + var editableColumnFields: Set { + guard canEditSchema else { return [] } + return StructureEditEligibility.editableFields(on: objectKind, matrix: matrix) + } + + var allowsAnyEdit: Bool { + canEditSchema && StructureEditEligibility.allowsAnyEdit(on: objectKind, matrix: matrix) + } + + /// Whether the engine has a statement for this operation at all, which is a different question + /// from whether the object's kind accepts it. An engine with no `ADD CONSTRAINT … FOREIGN KEY` + /// refuses it on a plain table too. + /// + /// Exhaustive on purpose. The column attribute changes carry no capability flag of their own and + /// are covered by `supportsSchemaEditing`, so a `default:` arm here would silently swallow a new + /// operation that does need one. + private func engineSupports(_ operation: StructureEditOperation) -> Bool { + switch operation { + case .addColumn: + return databaseType.supportsAddColumn + case .dropColumn: + return databaseType.supportsDropColumn + case .addIndex: + return databaseType.supportsAddIndex + case .dropIndex: + return databaseType.supportsDropIndex + case .addForeignKey, .dropForeignKey: + /// `resolve` answers these through `ForeignKeyEditPolicy` before reaching here. Stated + /// anyway so this switch stays total over the operations, which is what makes a new one + /// fail to compile until somebody decides its engine half. + return PluginManager.shared.foreignKeyEditSupport(for: databaseType).isEditable + case .addCheckConstraint, .dropCheckConstraint: + return databaseType.supportsCheckConstraintEditing + case .reorderColumns: + return PluginManager.shared.columnReorderSupport(for: databaseType) != .unsupported + case .renameColumn, .setNotNull, .dropNotNull, .setDefault, .dropDefault, + .changeColumnType, .redefineColumn, .commentOnColumn: + return true + } + } +} diff --git a/TablePro/Views/Structure/StructureEditingSession.swift b/TablePro/Views/Structure/StructureEditingSession.swift index b1b7ed654c..c0abfe3aad 100644 --- a/TablePro/Views/Structure/StructureEditingSession.swift +++ b/TablePro/Views/Structure/StructureEditingSession.swift @@ -43,6 +43,10 @@ internal final class StructureEditingSession { internal let schemaName: String? internal let tableName: String + /// What kind of object this session edits, which decides which edits it may offer at all. A tab + /// retargeted to another object gets a new session, so this never has to change under a session. + internal let objectKind: TableInfo.TableType + internal let changeManager = StructureChangeManager() /// Built here, not seeded into the view's `@State`. `State(wrappedValue:)` runs only the first @@ -73,6 +77,7 @@ internal final class StructureEditingSession { internal var sortState = SortState() internal var sortDescriptor: StructureSortDescriptor? internal var columnLayouts: [StructureTab: ColumnLayoutState] = [:] + internal var serverSupport = StructureServerSupport.unrestricted /// What the bottom bar offers while this tab is showing its structure. /// @@ -104,18 +109,21 @@ internal final class StructureEditingSession { connection: DatabaseConnection, databaseName: String, schemaName: String?, - tableName: String + tableName: String, + objectKind: TableInfo.TableType = .table ) { self.identity = identity self.connection = connection self.databaseName = databaseName self.schemaName = schemaName self.tableName = tableName + self.objectKind = objectKind gridDelegate = StructureGridDelegate( structureChangeManager: changeManager, selectedTab: .columns, connection: connection, tableName: tableName, + objectKind: objectKind, coordinator: nil ) gridDelegate.referenceMenus.schemaName = schemaName diff --git a/TablePro/Views/Structure/StructureFooterPolicy.swift b/TablePro/Views/Structure/StructureFooterPolicy.swift new file mode 100644 index 0000000000..3cf115738b --- /dev/null +++ b/TablePro/Views/Structure/StructureFooterPolicy.swift @@ -0,0 +1,90 @@ +// +// StructureFooterPolicy.swift +// TablePro +// +// What the add/remove pair under a structure list offers, and why it is dimmed when it is. +// + +import Foundation +import TableProPluginKit + +/// The single decision behind the "+" and "-" under the Structure tab's list. +/// +/// Pure and SwiftUI-free so the rule is testable, and one function so the label, the enabled state +/// and the tooltip can never disagree. They did: the labels came from one switch, the enabled state +/// from a second that read engine capability flags alone, and the tooltip from a third that returned +/// nil for every tab but Foreign Keys. So a view offered an enabled "Add Column" over an +/// `ALTER TABLE … ADD COLUMN` PostgreSQL always refuses, with nothing to explain it. (#2726) +enum StructureFooterPolicy { + /// Nil for a tab with nothing to add: DDL is text, Parts is read-only, and a trigger is created + /// through its own editor rather than by typing a row. + static func operation(forAdding tab: StructureTab) -> StructureEditOperation? { + switch tab { + case .columns: return .addColumn + case .indexes: return .addIndex + case .foreignKeys: return .addForeignKey + case .checkConstraints: return .addCheckConstraint + case .ddl, .parts, .triggers: return nil + } + } + + static func operation(forRemoving tab: StructureTab) -> StructureEditOperation? { + switch tab { + case .columns: return .dropColumn + case .indexes: return .dropIndex + case .foreignKeys: return .dropForeignKey + case .checkConstraints: return .dropCheckConstraint + case .ddl, .parts, .triggers: return nil + } + } + + static func labels(for tab: StructureTab) -> (add: String, remove: String)? { + switch tab { + case .columns: + return (String(localized: "Add Column"), String(localized: "Remove Column")) + case .indexes: + return (String(localized: "Add Index"), String(localized: "Remove Index")) + case .foreignKeys: + return (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key")) + case .checkConstraints: + return (String(localized: "Add Check Constraint"), String(localized: "Remove Check Constraint")) + case .ddl, .parts, .triggers: + return nil + } + } + + /// - Parameter resolve: The full availability of one operation, which the caller supplies because + /// it owns the connection the engine half of the answer comes from. + /// + /// A refusing object kind keeps the pair on screen and dims it with the reason, the way the + /// Foreign Keys tab already did. An engine that cannot edit structure at all hides it instead: + /// there is nothing to explain per object when the whole tab is read-only. + static func resolve( + tab: StructureTab, + canEditSchema: Bool, + hasSelection: Bool, + resolve: (StructureEditOperation) -> StructureEditAvailability + ) -> StructureFooterCapability { + guard canEditSchema, + let labels = labels(for: tab), + let adding = operation(forAdding: tab), + let removing = operation(forRemoving: tab) + else { + return StructureFooterCapability() + } + + let addAvailability = resolve(adding) + let removeAvailability = resolve(removing) + + return StructureFooterCapability( + canAdd: addAvailability.isAvailable, + canRemove: hasSelection && removeAvailability.isAvailable, + addLabel: labels.add, + removeLabel: labels.remove, + /// Never the absence of a selection. The pair dims while nothing is selected on every + /// tab and on every engine, which needs no sentence; only a refusal the user could not + /// have predicted does. + unavailableReason: addAvailability.unavailableReason ?? removeAvailability.unavailableReason + ) + } +} diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index abc9f5bd9f..7d2d7b1b4c 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -14,13 +14,31 @@ final class StructureGridDelegate: DataGridViewDelegate { var selectedTab: StructureTab let connection: DatabaseConnection - /// Whether this engine can add and remove foreign keys, which is not the same question as - /// whether it has them. Every path that stages a foreign key change reads this, not just the - /// button under the list: a context-menu Delete that stages one on an engine with no way to - /// apply it only fails later, at Save. + /// What kind of object this grid is editing. The footer pair is not the only way in: the Edit + /// menu's Add Row, the grid's own shortcut, the row context menu and the empty-space menu all + /// reach the methods below directly, so each of them asks the same gate rather than trusting a + /// button to have been dimmed. (#2726) + let objectKind: TableInfo.TableType + + /// The single answer to "may this edit be offered", shared with the view that presents this grid. + var editGate: StructureEditGate { + StructureEditGate(databaseType: connection.type, objectKind: objectKind) + } + + /// Whether this engine can add and remove foreign keys on this object, which is not the same + /// question as whether the engine has them. `supportsForeignKeys` answers the second, and reading + /// it as the first is what offered an enabled "+" on SQLite over a driver with no statement + /// behind it. Every path that stages a foreign key change reads this, not just the button under + /// the list: a context-menu Delete that stages one with no way to apply it only fails at Save. var canEditForeignKeys: Bool { - PluginManager.shared.foreignKeyEditSupport(for: connection.type).isEditable - && connection.type.supportsSchemaEditing + editGate.allows(.addForeignKey) + } + + /// Whether a new row may be staged on the list the user is looking at. Paste and Duplicate stage + /// the same add the "+" does, so they answer to the same gate rather than to the pasteboard alone. + var canStageAddForSelectedTab: Bool { + guard let adding = StructureFooterPolicy.operation(forAdding: selectedTab) else { return false } + return editGate.allows(adding) } let tableName: String /// The lists behind the Foreign Keys grid's reference cells, shared with the Create Table tab. @@ -43,6 +61,7 @@ final class StructureGridDelegate: DataGridViewDelegate { // Ordered fields for column editing (updated when currentProvider is set) var orderedFields: [StructureColumnField] = [] + var serverSupport = StructureServerSupport.unrestricted // Stored when DataGridView calls `dataGridAttach(tableViewCoordinator:)` on // every updateNSView. Lets us tell `NSTableView` which rows to reload after @@ -57,12 +76,14 @@ final class StructureGridDelegate: DataGridViewDelegate { selectedTab: StructureTab, connection: DatabaseConnection, tableName: String, + objectKind: TableInfo.TableType = .table, coordinator: MainContentCoordinator? ) { self.structureChangeManager = structureChangeManager self.selectedTab = selectedTab self.connection = connection self.tableName = tableName + self.objectKind = objectKind self.coordinator = coordinator self.referenceMenus = ForeignKeyReferenceMenus(connectionId: connection.id) } @@ -136,7 +157,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } case .checkConstraints: - guard connection.type.supportsCheckConstraintEditing, + guard editGate.allows(.addCheckConstraint), sourceRowIndex < structureChangeManager.workingCheckConstraints.count else { return } var constraint = structureChangeManager.workingCheckConstraints[sourceRowIndex] StructureEditingSupport.updateCheckConstraint(&constraint, at: column, with: newValue ?? "") @@ -176,7 +197,7 @@ final class StructureGridDelegate: DataGridViewDelegate { switch selectedTab { case .columns: - guard connection.type.supportsDropColumn else { return } + guard editGate.allows(.dropColumn) else { return } structureChangeManager.performAsOneUndoStep { for row in translated.sorted(by: >) { guard row < structureChangeManager.workingColumns.count else { continue } @@ -185,7 +206,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } } case .indexes: - guard connection.type.supportsDropIndex else { return } + guard editGate.allows(.dropIndex) else { return } structureChangeManager.performAsOneUndoStep { for row in translated.sorted(by: >) { guard row < structureChangeManager.workingIndexes.count else { continue } @@ -203,7 +224,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } } case .checkConstraints: - guard connection.type.supportsCheckConstraintEditing else { return } + guard editGate.allows(.addCheckConstraint) else { return } structureChangeManager.performAsOneUndoStep { for row in translated.sorted(by: >) { guard row < structureChangeManager.workingCheckConstraints.count else { continue } @@ -285,7 +306,8 @@ final class StructureGridDelegate: DataGridViewDelegate { } let displayProvider = currentProvider ?? StructureRowProvider( - changeManager: structureChangeManager, tab: selectedTab, databaseType: connection.type + changeManager: structureChangeManager, tab: selectedTab, databaseType: connection.type, + serverSupport: serverSupport ) var lines: [String] = [] for row in indices.sorted() { @@ -313,6 +335,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } func dataGridPasteRows() { + guard canStageAddForSelectedTab else { return } guard let data = NSPasteboard.general.data(forType: TableStructureView.structurePasteboardType), let jsonString = String(data: data, encoding: .utf8) else { return @@ -346,10 +369,9 @@ final class StructureGridDelegate: DataGridViewDelegate { } case .checkConstraints: - guard connection.type.supportsCheckConstraintEditing, - let constraints = try? decoder.decode( - [EditableCheckConstraintDefinition].self, from: Data(jsonString.utf8) - ) else { + guard let constraints = try? decoder.decode( + [EditableCheckConstraintDefinition].self, from: Data(jsonString.utf8) + ) else { return } for item in constraints { @@ -378,18 +400,15 @@ final class StructureGridDelegate: DataGridViewDelegate { } func dataGridAddRow() { + guard canStageAddForSelectedTab else { return } switch selectedTab { case .columns: - guard connection.type.supportsAddColumn else { return } structureChangeManager.addNewColumn() case .indexes: - guard connection.type.supportsAddIndex else { return } structureChangeManager.addNewIndex() case .foreignKeys: - guard canEditForeignKeys else { return } structureChangeManager.addNewForeignKey() case .checkConstraints: - guard connection.type.supportsCheckConstraintEditing else { return } structureChangeManager.addNewCheckConstraint() case .ddl, .parts, .triggers: break @@ -474,13 +493,6 @@ final class StructureGridDelegate: DataGridViewDelegate { rowView.isStructureEditable = connection.type.supportsSchemaEditing let src = sourceRow(for: row) - // Don't set `isDeleted` / visual state here. `DataGridView+Columns` - // calls `applyVisualState(visualState(for: row))` on every row view it - // returns from `tableView(_:rowViewForRow:)`. Setting it twice is a - // smell that previously hid the bug: when `applyVisualState` was a - // tint-only setter, this line was the only place the menu's - // `isDeleted` flag was assigned, and it was assigned only on row-view - // creation. Single source of truth now is `DataGridRowView.visualState`. if selectedTab == .foreignKeys, src < structureChangeManager.workingForeignKeys.count { rowView.referencedTableName = structureChangeManager.workingForeignKeys[src].referencedTable @@ -583,16 +595,16 @@ final class StructureGridDelegate: DataGridViewDelegate { let label: String switch selectedTab { case .columns: - guard connection.type.supportsAddColumn else { return nil } + guard editGate.allows(.addColumn) else { return nil } label = String(localized: "Add Column") case .indexes: - guard connection.type.supportsAddIndex else { return nil } + guard editGate.allows(.addIndex) else { return nil } label = String(localized: "Add Index") case .foreignKeys: guard canEditForeignKeys else { return nil } label = String(localized: "Add Foreign Key") case .checkConstraints: - guard connection.type.supportsCheckConstraintEditing else { return nil } + guard editGate.allows(.addCheckConstraint) else { return nil } label = String(localized: "Add Check Constraint") case .ddl, .parts, .triggers: return nil @@ -610,7 +622,8 @@ final class StructureGridDelegate: DataGridViewDelegate { private func handleCopyName(_ indices: Set) { let provider = StructureRowProvider( - changeManager: structureChangeManager, tab: selectedTab, databaseType: connection.type + changeManager: structureChangeManager, tab: selectedTab, databaseType: connection.type, + serverSupport: serverSupport ) let names = indices.sorted().compactMap { provider.row(at: $0)?.first ?? nil } guard !names.isEmpty else { return } @@ -662,7 +675,8 @@ final class StructureGridDelegate: DataGridViewDelegate { private func handleCopyAsCSV(_ indices: Set) { let provider = StructureRowProvider( changeManager: structureChangeManager, tab: selectedTab, - databaseType: connection.type, additionalFields: [.primaryKey] + databaseType: connection.type, additionalFields: [.primaryKey], + serverSupport: serverSupport ) let headers = provider.columns guard !headers.isEmpty else { return } @@ -688,7 +702,8 @@ final class StructureGridDelegate: DataGridViewDelegate { private func handleCopyAsJSON(_ indices: Set) { let provider = StructureRowProvider( changeManager: structureChangeManager, tab: selectedTab, - databaseType: connection.type, additionalFields: [.primaryKey] + databaseType: connection.type, additionalFields: [.primaryKey], + serverSupport: serverSupport ) let headers = provider.columns guard !headers.isEmpty else { return } @@ -713,6 +728,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } private func handleDuplicateItems(_ indices: Set) { + guard canStageAddForSelectedTab else { return } for row in indices.sorted() { switch selectedTab { case .columns: @@ -732,8 +748,7 @@ final class StructureGridDelegate: DataGridViewDelegate { let copy = structureChangeManager.workingForeignKeys[row] structureChangeManager.addForeignKey(copy.withNewIdentity()) case .checkConstraints: - guard connection.type.supportsCheckConstraintEditing, - row < structureChangeManager.workingCheckConstraints.count else { continue } + guard row < structureChangeManager.workingCheckConstraints.count else { continue } let copy = structureChangeManager.workingCheckConstraints[row] structureChangeManager.addCheckConstraint(copy.withNewIdentity()) case .ddl, .parts, .triggers: diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index 6c399342bf..9553ef639c 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -30,6 +30,7 @@ final class StructureRowProvider { private let tab: StructureTab private let databaseType: DatabaseType private let additionalFields: Set + private let serverSupport: StructureServerSupport let orderedColumnFields: [StructureColumnField] private let filterText: String? private let sortDescriptor: StructureSortDescriptor? @@ -122,7 +123,8 @@ final class StructureRowProvider { 6: GridMenuOption.values(updateActions.map(\.rawValue)) ] case .indexes: - let types = EditableIndexDefinition.IndexType.allCases.map(\.rawValue) + let offered = serverSupport.offeredIndexTypes(from: EditableIndexDefinition.IndexType.allCases) + let types = offered.map(\.rawValue) return [2: GridMenuOption.values(types), 3: GridMenuOption.values(Self.booleanOptions)] case .columns: var result: [Int: [GridMenuOption]] = [:] @@ -170,6 +172,7 @@ final class StructureRowProvider { tab: StructureTab, databaseType: DatabaseType = .mysql, additionalFields: Set = [], + serverSupport: StructureServerSupport, filterText: String? = nil, sortDescriptor: StructureSortDescriptor? = nil ) { @@ -177,9 +180,14 @@ final class StructureRowProvider { self.tab = tab self.databaseType = databaseType self.additionalFields = additionalFields + self.serverSupport = serverSupport self.filterText = filterText self.sortDescriptor = sortDescriptor - self.orderedColumnFields = Self.orderedFields(for: databaseType, additionalFields: additionalFields) + self.orderedColumnFields = Self.orderedFields( + for: databaseType, + additionalFields: additionalFields, + serverSupport: serverSupport + ) let allRows = Self.buildAllRows( tab: tab, changeManager: changeManager, orderedColumnFields: self.orderedColumnFields @@ -191,11 +199,12 @@ final class StructureRowProvider { static func orderedFields( for databaseType: DatabaseType, - additionalFields: Set = [] + additionalFields: Set = [], + serverSupport: StructureServerSupport ) -> [StructureColumnField] { let pluginFields = Set(PluginManager.shared.structureColumnFields(for: databaseType)) let fields = pluginFields.union(additionalFields) - return canonicalFieldOrder.filter { fields.contains($0) } + return canonicalFieldOrder.filter { fields.contains($0) && serverSupport.offers($0) } } // MARK: - Row Access diff --git a/TablePro/Views/Structure/StructureRowViewWithMenu.swift b/TablePro/Views/Structure/StructureRowViewWithMenu.swift index 7cabb81da2..6cd092ac5c 100644 --- a/TablePro/Views/Structure/StructureRowViewWithMenu.swift +++ b/TablePro/Views/Structure/StructureRowViewWithMenu.swift @@ -11,8 +11,8 @@ import AppKit /// Row view providing a context menu tailored to the Structure tab. Inherits /// selection/emphasis cell invalidation, deleted/inserted-row tint, and the /// `RowVisualState` source-of-truth from `DataGridRowView`. The context menu -/// reads `visualState.isDeleted` directly, so a single `applyVisualState` call -/// updates both the tint and the menu without a shadow flag to keep in sync. +/// reads the same live `visualState` the tint is drawn from, so the two cannot +/// disagree. final class StructureRowViewWithMenu: DataGridRowView { var structureTab: StructureTab = .columns var isStructureEditable: Bool = true diff --git a/TablePro/Views/Structure/StructureServerSupport.swift b/TablePro/Views/Structure/StructureServerSupport.swift new file mode 100644 index 0000000000..f5b26384a6 --- /dev/null +++ b/TablePro/Views/Structure/StructureServerSupport.swift @@ -0,0 +1,45 @@ +// +// StructureServerSupport.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct StructureServerSupport: Equatable, Sendable { + let unsupportedColumnFields: Set + let unsupportedIndexTypes: Set + + static let unrestricted = StructureServerSupport(unsupportedColumnFields: [], unsupportedIndexTypes: []) + + init(unsupportedColumnFields: Set, unsupportedIndexTypes: Set) { + self.unsupportedColumnFields = unsupportedColumnFields + self.unsupportedIndexTypes = Set(unsupportedIndexTypes.map { $0.uppercased() }) + } + + init(driver: (any DatabaseDriver)?) { + guard let driver else { + self = .unrestricted + return + } + self.init( + unsupportedColumnFields: driver.unsupportedStructureColumnFields, + unsupportedIndexTypes: driver.unsupportedIndexTypes + ) + } + + @MainActor + static func forConnection(_ connectionId: UUID) -> StructureServerSupport { + StructureServerSupport(driver: DatabaseManager.shared.driver(for: connectionId)) + } + + func offers(_ field: StructureColumnField) -> Bool { + !unsupportedColumnFields.contains(field) + } + + func offeredIndexTypes( + from types: [EditableIndexDefinition.IndexType] + ) -> [EditableIndexDefinition.IndexType] { + types.filter { !unsupportedIndexTypes.contains($0.rawValue.uppercased()) } + } +} diff --git a/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift b/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift index 6ca7a46a5c..e8b23fade7 100644 --- a/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift +++ b/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift @@ -20,7 +20,7 @@ extension TableStructureView { support: PluginManager.shared.columnReorderSupport(for: connection.type), engineName: connection.type.displayName, isColumnsTab: selectedTab == .columns, - isTable: !isViewObject, + kindRefusal: editGate.kindRefusal(.reorderColumns), canEditSchema: connection.type.supportsSchemaEditing, hasStagedChanges: structureChangeManager.hasChanges, isRearranged: !searchText.isEmpty || structureSortDescriptor != nil diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index b892ae260a..390702d411 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -70,21 +70,7 @@ extension TableStructureView { case .ddl: let table = tableName ddlStatement = try await structureLoader.perform { driver in - let sequences = try await driver.fetchDependentSequences(forTable: table) - let enumTypes = try await driver.fetchDependentTypes(forTable: table) - let baseDDL = try await driver.fetchTableDDL(table: table) - let indexDDL = (try? await driver.fetchIndexDDL(table: table)) ?? [] - var preamble = "" - for seq in sequences { - preamble += seq.ddl + "\n\n" - } - for enumType in enumTypes { - let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\"" - let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0))'" } - preamble += "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n" - } - return TableDDLComposer.compose( - tableDDL: baseDDL, indexDDL: indexDDL, preamble: preamble) + try await TableDDLComposer.fetchDDL(for: table, using: driver, includesDependencies: true) } case .triggers: do { @@ -104,6 +90,7 @@ extension TableStructureView { } func loadSchemaForEditing() { + session.serverSupport = StructureServerSupport.forConnection(connection.id) let pkFromIndexes = indexes.first(where: { $0.isPrimary })?.columns ?? [] let pkFromColumns = columns.filter { $0.isPrimaryKey }.map { $0.name } let primaryKey = pkFromIndexes.isEmpty ? pkFromColumns : pkFromIndexes diff --git a/TablePro/Views/Structure/TableStructureView+EditGate.swift b/TablePro/Views/Structure/TableStructureView+EditGate.swift new file mode 100644 index 0000000000..773e5947ef --- /dev/null +++ b/TablePro/Views/Structure/TableStructureView+EditGate.swift @@ -0,0 +1,67 @@ +// +// TableStructureView+EditGate.swift +// TablePro +// +// Which structure edits this tab offers, for the object it is open on. +// + +import Foundation +import TableProPluginKit + +extension TableStructureView { + /// Every "may this edit be offered" question the tab asks, answered from one place. The footer + /// pair, the grid's per-column lock, the reorder drag and the grid delegate's own keyboard and + /// context-menu paths all read this, so none of them can offer an edit another withholds. + var editGate: StructureEditGate { + StructureEditGate(databaseType: connection.type, objectKind: objectKind) + } + + /// Whether this engine can add and remove foreign keys, which is not the same question as + /// whether it has them. `supportsForeignKeys` answers the second, and reading it as the first + /// is what offered an enabled "+" on SQLite over a driver with no statement behind it. + var foreignKeyEditAvailability: ForeignKeyEditAvailability { + ForeignKeyEditPolicy.resolve( + support: PluginManager.shared.foreignKeyEditSupport(for: connection.type), + engineName: connection.type.displayName, + kindRefusal: editGate.kindRefusal(.addForeignKey), + canEditSchema: connection.type.supportsSchemaEditing + ) + } + + /// Published to the tab's own session, which the bottom bar reads. Nothing is cleared on + /// disappear: the session outlives the view by design, and the bar only reads this while the + /// tab is showing its structure. + func publishFooterCapability() { + let gate = editGate + session.footer = StructureFooterPolicy.resolve( + tab: selectedTab, + canEditSchema: connection.type.supportsSchemaEditing, + hasSelection: !selectedRows.isEmpty, + resolve: { gate.resolve($0) } + ) + } + + /// The Columns grid's headings for the fields this object's kind will not let the user change. + /// + /// Name-keyed because that is the only handle the grid has: `isColumnWritable` is asked about a + /// column's heading, and the Columns grid's headings are exactly + /// `orderedColumnFields.map(\.displayName)`. Empty on every tab but Columns, whose rows are the + /// only ones that describe a column. + var lockedStructureColumns: Set { + guard selectedTab == .columns else { return [] } + let editable = editGate.editableColumnFields + let locked = StructureColumnField.allCases.filter { !editable.contains($0) } + /// `displayName` is a `String(localized:)` lookup per field, and this is read on every body + /// evaluation. A table locks nothing, which is the overwhelmingly common case, so it never + /// pays for twelve of them. + guard !locked.isEmpty else { return [] } + return Set(locked.map(\.displayName)) + } + + /// Why the Columns grid refuses every keystroke, when it does. A grid that will not take an edit + /// and says nothing reads as broken, so the pointer carries this as the grid's tooltip. + var structureEditRefusal: String? { + guard !editGate.allowsAnyEdit else { return nil } + return editGate.resolve(.renameColumn).unavailableReason + } +} diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index ea56cc249c..1a9c4c9fec 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -110,7 +110,7 @@ extension TableStructureView { if showCopyConfirmation { HStack { Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusSuccess)) Text("Copied!") } .transition(.opacity) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 838c349340..c81ba75f00 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -30,10 +30,6 @@ struct TableStructureView: View { let databaseName: String let schemaName: String? - /// Whether the Structure tab is open on a view rather than a table. Every reorder mechanism - /// emits table DDL, so a view is withheld rather than allowed to fail at the statement. - var isViewObject: Bool = false - let toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator? let selectionState: GridSelectionState @@ -53,6 +49,16 @@ struct TableStructureView: View { /// view because the view is destroyed whenever the tab is deselected or switched to Data. let session: StructureEditingSession + /// What kind of object the tab is open on, which decides every edit it may offer. + /// + /// The real `TableInfo.TableType`, read from the session rather than passed in beside it, so the + /// grid delegate the session owns and the footer this view publishes can never disagree about + /// what they are looking at. It used to be an `isView` Bool derived from `allowsRowEditing`, + /// which is true for a materialized view, so a matview reached here as a table and was offered + /// `ADD COLUMN`, `SET NOT NULL`, type changes and constraint edits the server always refuses. + /// (#2726) + var objectKind: TableInfo.TableType { session.objectKind } + /// Where the user was. Two tabs on one table are two editors, and a trip through the Data view /// must not lose the sub-tab, filter or sort either, so all of it lives on the session. var selectedTab: StructureTab { @@ -153,7 +159,6 @@ struct TableStructureView: View { connection: DatabaseConnection, databaseName: String, schemaName: String?, - isViewObject: Bool = false, toolbarState: ConnectionToolbarState, coordinator: MainContentCoordinator?, selectionState: GridSelectionState, @@ -163,7 +168,6 @@ struct TableStructureView: View { self.connection = connection self.databaseName = databaseName self.schemaName = schemaName - self.isViewObject = isViewObject self.toolbarState = toolbarState self.coordinator = coordinator self.selectionState = selectionState @@ -326,80 +330,6 @@ struct TableStructureView: View { .padding() } - // MARK: - Footer capability - - /// Published to the tab's own session, which the bottom bar reads. Nothing is cleared on - /// disappear: the session outlives the view by design, and the bar only reads this while the - /// tab is showing its structure. - private func publishFooterCapability() { - guard connection.type.supportsSchemaEditing, let labels = footerLabels(for: selectedTab) else { - session.footer = StructureFooterCapability() - return - } - session.footer = StructureFooterCapability( - canAdd: canAdd(for: selectedTab), - canRemove: canRemove(for: selectedTab), - addLabel: labels.add, - removeLabel: labels.remove, - unavailableReason: unavailableReason(for: selectedTab) - ) - } - - /// Whether this engine can add and remove foreign keys, which is not the same question as - /// whether it has them. `supportsForeignKeys` answers the second, and reading it as the first - /// is what offered an enabled "+" on SQLite over a driver with no statement behind it. - var foreignKeyEditAvailability: ForeignKeyEditAvailability { - ForeignKeyEditPolicy.resolve( - support: PluginManager.shared.foreignKeyEditSupport(for: connection.type), - engineName: connection.type.displayName, - isTable: !isViewObject, - canEditSchema: connection.type.supportsSchemaEditing - ) - } - - private func canAdd(for tab: StructureTab) -> Bool { - switch tab { - case .columns: return connection.type.supportsAddColumn - case .indexes: return connection.type.supportsAddIndex - case .foreignKeys: return foreignKeyEditAvailability.isAvailable - case .checkConstraints: return connection.type.supportsCheckConstraintEditing - case .ddl, .parts, .triggers: return false - } - } - - /// Why the pair under the list is dimmed, for its tooltip. Nil when it is not, and nil for a - /// tab whose absence needs no explaining: DDL and Parts have nothing to add. - private func unavailableReason(for tab: StructureTab) -> String? { - guard tab == .foreignKeys else { return nil } - return foreignKeyEditAvailability.unavailableReason - } - - private func canRemove(for tab: StructureTab) -> Bool { - guard !selectedRows.isEmpty else { return false } - switch tab { - case .columns: return connection.type.supportsDropColumn - case .indexes: return connection.type.supportsDropIndex - case .foreignKeys: return foreignKeyEditAvailability.isAvailable - case .checkConstraints: return connection.type.supportsCheckConstraintEditing - case .ddl, .parts, .triggers: return false - } - } - - private func footerLabels(for tab: StructureTab) -> (add: String, remove: String)? { - switch tab { - case .columns: - return (String(localized: "Add Column"), String(localized: "Remove Column")) - case .indexes: - return (String(localized: "Add Index"), String(localized: "Remove Index")) - case .foreignKeys: - return (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key")) - case .checkConstraints: - return (String(localized: "Add Check Constraint"), String(localized: "Remove Check Constraint")) - case .ddl, .parts, .triggers: - return nil - } - } - // MARK: - Tab Label with Count Badge private func tabLabel(for tab: StructureTab) -> String { @@ -472,25 +402,27 @@ struct TableStructureView: View { } } + /// Only offered where the add behind it can actually run. An engine that lists an object but + /// cannot edit it shows the grid, so its real rows stay visible instead of being replaced by an + /// empty state whose only affordance is disabled, and a view whose kind refuses the add never + /// gets the empty state's button at all. private var shouldShowIndexesEmptyState: Bool { tabData.hasData(.indexes) && structureChangeManager.workingIndexes.isEmpty - && connection.type.supportsAddIndex + && editGate.allows(.addIndex) } private var shouldShowForeignKeysEmptyState: Bool { tabData.hasData(.foreignKeys) && structureChangeManager.workingForeignKeys.isEmpty && connection.type.supportsForeignKeys + && editGate.allows(.addForeignKey) } - /// Only offered where the engine can actually add one. An engine that lists constraints but - /// cannot edit them shows the grid, so a table's real constraints stay visible instead of being - /// replaced by an empty state whose only affordance is disabled. private var shouldShowCheckConstraintsEmptyState: Bool { tabData.hasData(.checkConstraints) && structureChangeManager.workingCheckConstraints.isEmpty - && connection.type.supportsCheckConstraintEditing + && editGate.allows(.addCheckConstraint) } // MARK: - Structure Grid (DataGridView) @@ -501,6 +433,7 @@ struct TableStructureView: View { tab: selectedTab, databaseType: connection.type, additionalFields: [.primaryKey], + serverSupport: session.serverSupport, filterText: searchText.isEmpty ? nil : searchText, sortDescriptor: structureSortDescriptor ) @@ -517,6 +450,7 @@ struct TableStructureView: View { let provider = makeCurrentProvider() gridDelegate.selectedTab = selectedTab + gridDelegate.serverSupport = session.serverSupport gridDelegate.currentProvider = provider gridDelegate.orderedFields = provider.orderedColumnFields coordinator?.inspectorRowSourceRevision += 1 @@ -534,9 +468,12 @@ struct TableStructureView: View { private var structureGrid: some View { @Bindable var session = session let provider = makeCurrentProvider() - let canEdit = connection.type.supportsSchemaEditing + let canEdit = editGate.allowsAnyEdit let customOptions = provider.customDropdownOptions let allDropdownColumns = provider.dropdownColumns + /// Resolved once. It reads the engine's curated capabilities and the object's own kind, and + /// this is a body property, so asking twice for the pair of values below doubled that work. + let reorder = columnReorderAvailability // Build the row snapshot fresh on every call rather than capturing it // once at body-evaluation time. After a cell edit / undo / redo the @@ -560,12 +497,14 @@ struct TableStructureView: View { tableName: tableName, databaseName: databaseName, schemaName: schemaName, - tabType: .table + tabType: .table, + lockedColumns: lockedStructureColumns, + editRefusalMessage: structureEditRefusal ), delegate: gridDelegate, rowReorder: DataGridRowReorder( - isEnabled: columnReorderAvailability.isAvailable, - unavailableReason: columnReorderAvailability.unavailableReason + isEnabled: reorder.isAvailable, + unavailableReason: reorder.unavailableReason ), selectedRowIndices: $selectedRows, sortState: $session.sortState, @@ -588,7 +527,7 @@ struct TableStructureView: View { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .accessibilityHidden(true) RevealedTextView(message) .foregroundStyle(.secondary) diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 54aae67b7e..ca27c0b2ef 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -252,7 +252,7 @@ private struct TriggerListPane: View { private func enabledIndicator(_ trigger: TriggerInfo) -> some View { if let enabled = trigger.enabled { Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(enabled ? Color.green : Color.secondary) + .foregroundStyle(enabled ? ThemeEngine.shared.palette.color(.statusSuccess) : Color.secondary) .accessibilityLabel(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) } } @@ -274,7 +274,7 @@ private struct TriggerDetailPane: View { onOpenInEditor: { onOpenInEditor(trigger) } ) } else { - Color(nsColor: .textBackgroundColor) + ThemeEngine.shared.palette.color(.editorBackground) } } diff --git a/TablePro/Views/Structure/TriggerEditorView.swift b/TablePro/Views/Structure/TriggerEditorView.swift index dbcc165e2c..010c68c572 100644 --- a/TablePro/Views/Structure/TriggerEditorView.swift +++ b/TablePro/Views/Structure/TriggerEditorView.swift @@ -75,6 +75,9 @@ struct TriggerEditorView: View { .onChange(of: colorScheme) { editorConfiguration = Self.makeConfiguration(fontSize: fontSize) } + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration(fontSize: fontSize) + } .onChange(of: fontSize) { _, newSize in editorConfiguration = Self.makeConfiguration(fontSize: newSize) } diff --git a/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/Contents.json new file mode 100644 index 0000000000..eb0b7fe6e2 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "oceanbase.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg new file mode 100644 index 0000000000..a423b01f5c --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg @@ -0,0 +1 @@ + diff --git a/TableProMobile/TableProMobile/Assets.xcassets/weaviate-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/weaviate-icon.imageset/Contents.json new file mode 100644 index 0000000000..fcfa2ae760 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/weaviate-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "weaviate.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/weaviate-icon.imageset/weaviate.svg b/TableProMobile/TableProMobile/Assets.xcassets/weaviate-icon.imageset/weaviate.svg new file mode 100644 index 0000000000..fafccd810f --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/weaviate-icon.imageset/weaviate.svg @@ -0,0 +1 @@ +Weaviate diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index a52072da93..14cffc8d25 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -51,7 +51,7 @@ final class ConnectionCoordinator { var supportsDatabaseSwitching: Bool { connection.type == .mysql || connection.type == .mariadb || - connection.type == .tidb || + connection.type == .tidb || connection.type == .oceanbase || connection.type == .postgresql || connection.type == .redshift || connection.type == .mssql } diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 2f89f9de0d..a53b40ac8b 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -3,6 +3,7 @@ import Foundation import TableProDatabase import TableProModels import TableProMSSQLCore +import TableProPluginKit nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { private let actor = MySQLActor() @@ -13,6 +14,7 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { private let database: String let ssl: DriverSSLConfiguration let databaseType: DatabaseType + private let connectionEncoding: MySQLConnectionEncoding var supportsSchemas: Bool { false } var currentSchema: String? { nil } @@ -32,7 +34,8 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { password: String, database: String, ssl: DriverSSLConfiguration = .disabled, - databaseType: DatabaseType = .mysql + databaseType: DatabaseType = .mysql, + connectionEncoding: MySQLConnectionEncoding = .utf8 ) { self.host = host self.port = port @@ -41,13 +44,17 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { self.database = database self.ssl = ssl self.databaseType = databaseType + self.connectionEncoding = connectionEncoding } // MARK: - Connection func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) - try await actor.connect(host: host, port: port, user: user, password: password, database: database, ssl: ssl) + try await actor.connect( + host: host, port: port, user: user, password: password, database: database, + ssl: ssl, encoding: connectionEncoding + ) serverVersion = await actor.serverVersion() } @@ -271,7 +278,13 @@ private actor MySQLActor { private static let connectDeadline: DispatchTimeInterval = .seconds(15) - func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration) async throws { + private var encoding: MySQLConnectionEncoding = .utf8 + + func connect( + host: String, port: Int, user: String, password: String, database: String, + ssl: DriverSSLConfiguration, encoding: MySQLConnectionEncoding + ) async throws { + self.encoding = encoding // Close existing connection if reconnecting if let mysql { mysql_close(mysql); self.mysql = nil } @@ -279,7 +292,7 @@ private actor MySQLActor { throw MySQLError.connectionFailed("Failed to initialize MySQL client") } - mysql_options(handle, MYSQL_SET_CHARSET_NAME, "utf8mb4") + mysql_options(handle, MYSQL_SET_CHARSET_NAME, MySQLConnectionEncoding.sessionCharacterSetName) var timeout: UInt32 = 10 mysql_options(handle, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) @@ -333,7 +346,13 @@ private actor MySQLActor { ) guard connected else { - let msg = String(cString: mysql_error(handle)) + let msg = message(from: handle) + mysql_close(handle) + throw MySQLError.connectionFailed(msg) + } + + guard MariaDBCharacterSet.establishSession(on: handle, encoding: encoding) else { + let msg = message(from: handle) mysql_close(handle) throw MySQLError.connectionFailed(msg) } @@ -351,11 +370,37 @@ private actor MySQLActor { func ping() throws -> Bool { guard let mysql else { throw MySQLError.notConnected } if mysql_ping(mysql) != 0 { - throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + throw MySQLError.queryFailed(message(from: mysql)) } return true } + private func legacyText(_ value: PluginCellValue) -> String? { + switch value { + case .null: + return nil + case .text(let text): + return text + case .bytes(let data): + return Self.hexText(data) + @unknown default: + return nil + } + } + + private static func hexText(_ data: Data) -> String { + let shown = data.prefix(hexPreviewBytes) + let hex = shown.map { String(format: "%02X", $0) }.joined() + return data.count > hexPreviewBytes ? "0x\(hex)…" : "0x\(hex)" + } + + private static let hexPreviewBytes = 256 + + private func message(from mysql: UnsafeMutablePointer) -> String { + guard let text = mysql_error(mysql) else { return "" } + return mysqlSessionText(cString: text, encoding: encoding) + } + func serverVersion() -> String? { guard let mysql else { return nil } return String(cString: mysql_get_server_info(mysql)) @@ -367,12 +412,12 @@ private actor MySQLActor { let start = Date() guard mysql_real_query(mysql, query, UInt(query.utf8.count)) == 0 else { - throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + throw MySQLError.queryFailed(message(from: mysql)) } guard let result = mysql_store_result(mysql) else { if mysql_field_count(mysql) != 0 { - throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + throw MySQLError.queryFailed(message(from: mysql)) } let raw = mysql_affected_rows(mysql) let affected = raw == .max ? 0 : Int(clamping: raw) @@ -384,16 +429,11 @@ private actor MySQLActor { defer { mysql_free_result(result) } let fieldCount = Int(mysql_num_fields(result)) - var columns: [String] = [] - var columnTypes: [String] = [] - - if let fields = mysql_fetch_fields(result) { - for i in 0..= maxRows @@ -435,6 +468,7 @@ private actor MySQLActor { private var streamingResult: UnsafeMutablePointer? private var streamingColumns: [ColumnInfo] = [] + private var streamingDecoding = MySQLResultColumns() func beginStream(query: String) throws -> MySQLBeginStreamResult { guard let mysql else { throw MySQLError.notConnected } @@ -443,12 +477,12 @@ private actor MySQLActor { } guard mysql_real_query(mysql, query, UInt(query.utf8.count)) == 0 else { - throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + throw MySQLError.queryFailed(message(from: mysql)) } guard let result = mysql_use_result(mysql) else { if mysql_field_count(mysql) != 0 { - throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + throw MySQLError.queryFailed(message(from: mysql)) } let raw = mysql_affected_rows(mysql) let affected = raw == .max ? 0 : Int(clamping: raw) @@ -457,23 +491,21 @@ private actor MySQLActor { streamingResult = result - let fieldCount = Int(mysql_num_fields(result)) - var columns: [ColumnInfo] = [] - if let fields = mysql_fetch_fields(result) { - for i in 0.. CellRef? { @@ -521,7 +557,8 @@ private actor MySQLActor { for pkColumn in lazyContext.primaryKeyColumns { guard let columnIndex = columns.firstIndex(where: { $0.name == pkColumn }) else { return nil } guard let cValue = row[columnIndex] else { return nil } - pkComponents.append(PrimaryKeyComponent(column: pkColumn, value: String(cString: cValue))) + let value = mysqlSessionText(cString: cValue, encoding: encoding) + pkComponents.append(PrimaryKeyComponent(column: pkColumn, value: value)) } return CellRef(table: lazyContext.table, column: column, primaryKey: pkComponents) } @@ -532,39 +569,6 @@ nonisolated enum MySQLBeginStreamResult: Sendable { case noResult(affectedRows: Int) } -// MARK: - MySQL Field Type Names - -nonisolated private func mysqlFieldTypeName(_ typeValue: UInt32) -> String { - switch typeValue { - case 0: return "DECIMAL" - case 1: return "TINYINT" - case 2: return "SMALLINT" - case 3: return "INT" - case 4: return "FLOAT" - case 5: return "DOUBLE" - case 6: return "NULL" - case 7: return "TIMESTAMP" - case 8: return "BIGINT" - case 9: return "MEDIUMINT" - case 10: return "DATE" - case 11: return "TIME" - case 12: return "DATETIME" - case 13: return "YEAR" - case 15: return "VARCHAR" - case 16: return "BIT" - case 245: return "JSON" - case 246: return "NEWDECIMAL" - case 249: return "TINYTEXT" - case 250: return "MEDIUMTEXT" - case 251: return "LONGTEXT" - case 252: return "TEXT" - case 253: return "VARCHAR" - case 254: return "CHAR" - case 255: return "GEOMETRY" - default: return "UNKNOWN" - } -} - nonisolated private struct RawMySQLResult: Sendable { let columns: [String] let columnTypes: [String] diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift index fb6d97b91e..38101239c5 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLConnectionString.swift @@ -2,6 +2,7 @@ import Foundation nonisolated enum PostgreSQLConnectionString { static let connectTimeoutSeconds = 10 + static let clientEncoding = "UTF8" static func build( host: String, @@ -19,6 +20,7 @@ nonisolated enum PostgreSQLConnectionString { ("password", password), ("connect_timeout", String(connectTimeoutSeconds)), ("sslmode", ssl.postgresSSLMode), + ("client_encoding", clientEncoding), ] if let caPath = ssl.existingCACertificatePath { diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLCopyState.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLCopyState.swift new file mode 100644 index 0000000000..9c29e5cdf2 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLCopyState.swift @@ -0,0 +1,14 @@ +import Foundation + +nonisolated extension LibPQCopyDirection { + var unsupportedMessage: String { + switch self { + case .copyIn: + return String(localized: "TablePro cannot send data to COPY FROM STDIN, so no rows were sent. Insert the rows with INSERT instead.") + case .copyOut: + return String(localized: "TablePro cannot receive the output of COPY TO STDOUT, so it was discarded. Run a SELECT instead.") + case .copyBoth: + return String(localized: "TablePro cannot run a replication COPY.") + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 41f1c9bd67..e11f97681f 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -1,7 +1,9 @@ import CLibPQ import Foundation +import os import TableProDatabase import TableProModels +import TableProPluginKit nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { private let actor = PostgreSQLActor() @@ -18,6 +20,7 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { // Set once during connect()/switchSchema() before the driver is shared — safe for concurrent reads nonisolated(unsafe) private(set) var currentSchema: String? = "public" nonisolated(unsafe) private(set) var serverVersion: String? + nonisolated(unsafe) private(set) var serverVersionNumber: Int32 = 0 nonisolated(unsafe) private var reportsIdentityColumns: Bool? @@ -39,6 +42,7 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { try await actor.connect(host: host, port: port, user: user, password: password, database: database, ssl: ssl) _ = try? await actor.execute("SET standard_conforming_strings = on") serverVersion = await actor.serverVersion() + serverVersionNumber = await actor.serverVersionNumber() await adoptServerSchema() } @@ -94,6 +98,10 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { let beginResult = try await actor.beginStream(query: query) switch beginResult { case .commandOk(let affectedRows): + if let abandoned = await actor.takeAbandonedCopyError() { + continuation.finish(throwing: abandoned) + return + } if affectedRows != 0 { continuation.yield(.rowsAffected(affectedRows)) } @@ -115,6 +123,10 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { continuation.yield(.truncated(reason: .rowCap(options.maxRows))) } await actor.endStream() + if let abandoned = await actor.takeAbandonedCopyError() { + continuation.finish(throwing: abandoned) + return + } continuation.finish() } } catch is CancellationError { @@ -277,51 +289,37 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { } func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { - let schemaName = schema ?? effectiveSchema - let safeTbl = table.replacingOccurrences(of: "'", with: "''") - let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''") - - let raw = try await actor.execute(""" - SELECT - tc.constraint_name, - kcu.column_name, - ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column, - rc.delete_rule, - rc.update_rule - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON tc.constraint_name = ccu.constraint_name - AND tc.table_schema = ccu.table_schema - JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - AND tc.table_schema = rc.constraint_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = '\(safeSchema)' - AND tc.table_name = '\(safeTbl)' - ORDER BY tc.constraint_name - """) + let raw = try await actor.execute( + Self.foreignKeysQuery( + schema: schema ?? effectiveSchema, + table: table, + serverVersionNumber: serverVersionNumber + ) + ) - return raw.rows.compactMap { row in - guard row.count >= 6, - let name = row[0], - let column = row[1], - let refTable = row[2], - let refColumn = row[3] else { return nil } - return ForeignKeyInfo( - name: name, - column: column, - referencedTable: refTable, - referencedColumn: refColumn, - onDelete: row[4] ?? "NO ACTION", - onUpdate: row[5] ?? "NO ACTION" + return PostgreSQLCatalogForeignKeys.foreignKeys(from: raw.rows).map { key in + ForeignKeyInfo( + name: key.name, + column: key.column, + referencedTable: key.referencedTable, + referencedColumn: key.referencedColumn, + referencedSchema: key.referencedSchema, + onDelete: key.onDelete, + onUpdate: key.onUpdate ) } } + static func foreignKeysQuery(schema: String, table: String, serverVersionNumber: Int32) -> String { + PostgreSQLCatalogForeignKeys.query( + schema: schema, + table: table, + excludesPartitionClones: PostgreSQLCatalogForeignKeys.excludesPartitionClones( + serverVersionNumber: serverVersionNumber + ) + ) + } + func fetchDatabases() async throws -> [String] { let raw = try await actor.execute( "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname" @@ -460,6 +458,11 @@ private actor PostgreSQLActor { } } + func serverVersionNumber() -> Int32 { + guard let conn else { return 0 } + return PQserverVersion(conn) + } + func serverVersion() -> String? { guard let conn else { return nil } let version = PQserverVersion(conn) @@ -479,6 +482,7 @@ private actor PostgreSQLActor { guard let conn else { throw PostgreSQLError.notConnected } let start = Date() + let cancelsOutput = cancelsAbandonedOutput(conn) let result = PQexec(conn, query) defer { if result != nil { PQclear(result) } @@ -486,6 +490,11 @@ private actor PostgreSQLActor { let status = PQresultStatus(result) + if let copy = result.flatMap(LibPQCopyState.copy(of:)) { + _ = finishPendingResults(conn, cancellingOutput: cancelsOutput) + throw PostgreSQLError.unsupported(copy.direction.unsupportedMessage) + } + if status == PGRES_FATAL_ERROR { let msg = result.flatMap { String(cString: PQresultErrorMessage($0)) } ?? "Unknown error" throw PostgreSQLError.queryFailed(msg) @@ -546,10 +555,14 @@ private actor PostgreSQLActor { private var pendingResult: OpaquePointer? private var streamingFinished = true + private var abandonedCopy: LibPQCopy? + private var streamCancelsAbandonedOutput = false func beginStream(query: String) throws -> PGBeginStreamResult { guard let conn else { throw PostgreSQLError.notConnected } + streamCancelsAbandonedOutput = cancelsAbandonedOutput(conn) endStream() + abandonedCopy = nil guard PQsendQuery(conn, query) == 1 else { throw PostgreSQLError.queryFailed(String(cString: PQerrorMessage(conn))) @@ -566,6 +579,12 @@ private actor PostgreSQLActor { } let status = PQresultStatus(firstResult) + if let copy = LibPQCopyState.copy(of: firstResult) { + PQclear(firstResult) + drainResults() + abandonedCopy = nil + throw PostgreSQLError.unsupported(copy.direction.unsupportedMessage) + } switch status { case PGRES_COMMAND_OK: let affectedStr = String(cString: PQcmdTuples(firstResult)) @@ -654,11 +673,37 @@ private actor PostgreSQLActor { pendingResult = nil } guard let conn else { return } - while let extra = PQgetResult(conn) { - PQclear(extra) - } + let outcome = finishPendingResults(conn, cancellingOutput: streamCancelsAbandonedOutput) + guard let copy = outcome.abandonedCopy, abandonedCopy == nil else { return } + abandonedCopy = copy } + /// A COPY the drain ended is reported, never swallowed: `INSERT INTO t VALUES (1); COPY t FROM + /// STDIN` used to stream as a plain "INSERT 0 1" with the COPY silently discarded. + func takeAbandonedCopyError() -> PostgreSQLError? { + guard let copy = abandonedCopy else { return nil } + abandonedCopy = nil + return PostgreSQLError.unsupported(copy.direction.unsupportedMessage) + } + + private func finishPendingResults(_ conn: OpaquePointer, cancellingOutput: Bool) -> LibPQDrainOutcome { + let outcome = LibPQCopyState.finishPendingResults(conn, cancellingOutput: cancellingOutput) + guard let stuck = outcome.stuckInCopy else { return outcome } + Self.logger.fault( + "libpq stayed in \(String(describing: stuck.direction), privacy: .public); dropping the connection" + ) + close() + return outcome + } + + /// Cancelling inside a transaction block aborts it, so the cancel that keeps a `COPY TO STDOUT` + /// from transferring the whole table is sent only outside one. + private func cancelsAbandonedOutput(_ conn: OpaquePointer) -> Bool { + PQtransactionStatus(conn) != PQTRANS_INTRANS + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "PostgreSQLActor") + private func parseColumns(_ result: OpaquePointer) -> [ColumnInfo] { let colCount = Int(PQnfields(result)) var cols: [ColumnInfo] = [] diff --git a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift index 3b1badeebf..bfce9ab524 100644 --- a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift +++ b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift @@ -7,6 +7,7 @@ extension DatabaseType { case .mysql, .mariadb: return "3306" case .tidb: return "4000" case .databend: return "3307" + case .oceanbase: return "2881" case .postgresql: return "5432" case .redshift: return "5439" case .redis: return "6379" @@ -23,6 +24,7 @@ extension DatabaseType { case .mariadb: "MariaDB" case .tidb: "TiDB" case .databend: "Databend" + case .oceanbase: "OceanBase" case .postgresql: "PostgreSQL" case .redshift: "Redshift" case .sqlite: "SQLite" @@ -38,6 +40,7 @@ extension DatabaseType { .mysql, .mariadb, .tidb, + .oceanbase, .postgresql, .sqlite, .duckdb, diff --git a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift index a2991128a1..32cebec073 100644 --- a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift +++ b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift @@ -9,7 +9,7 @@ struct IntentDatabaseSession { static func supportsTabularInsert(_ type: DatabaseType) -> Bool { switch type { - case .mysql, .mariadb, .tidb, .postgresql, .redshift, .mssql, .sqlite, .duckdb, .oracle: + case .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .mssql, .sqlite, .duckdb, .oracle: return true default: return false diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 1752290732..789ca5b504 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -33,7 +33,7 @@ nonisolated final class IOSDriverFactory: DriverFactory { ? nil : bookmarkStore.bookmark(for: connection.id) return DuckDBDriver(path: connection.database, bookmark: bookmark) - case .mysql, .mariadb, .tidb: + case .mysql, .mariadb, .tidb, .oceanbase: return MySQLDriver( host: connection.host, port: connection.port, @@ -41,7 +41,8 @@ nonisolated final class IOSDriverFactory: DriverFactory { password: password ?? "", database: connection.database, ssl: try ssl(for: connection), - databaseType: connection.type + databaseType: connection.type, + connectionEncoding: MySQLConnectionEncoding(additionalFields: connection.additionalFields) ) case .postgresql, .redshift: return PostgreSQLDriver( @@ -75,6 +76,6 @@ nonisolated final class IOSDriverFactory: DriverFactory { } func supportedTypes() -> [DatabaseType] { - [.sqlite, .duckdb, .mysql, .mariadb, .tidb, .postgresql, .redshift, .redis, .mssql, .oracle] + [.sqlite, .duckdb, .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .redis, .mssql, .oracle] } } diff --git a/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift b/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift index 458e4c3fdd..917b33d668 100644 --- a/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift +++ b/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift @@ -30,6 +30,7 @@ struct DatabaseIconView: View { case .mysql, .mariadb: return .orange case .tidb: return .red case .databend: return .blue + case .oceanbase: return .blue case .postgresql, .redshift: return .blue case .sqlite: return .green case .redis: return .red diff --git a/TableProMobile/TableProMobileTests/Drivers/MySQLSharedDecodingTests.swift b/TableProMobile/TableProMobileTests/Drivers/MySQLSharedDecodingTests.swift new file mode 100644 index 0000000000..8bb79194ba --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/MySQLSharedDecodingTests.swift @@ -0,0 +1,43 @@ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("MySQL shared decoding on iOS") +struct MySQLSharedDecodingTests { + @Test("The connection's Encoding field reaches the driver") + func encodingFieldIsRead() { + #expect(MySQLConnectionEncoding(additionalFields: [:]) == .utf8) + #expect(MySQLConnectionEncoding(additionalFields: ["mysqlConnectionEncoding": ""]) == .utf8) + #expect( + MySQLConnectionEncoding(additionalFields: ["mysqlConnectionEncoding": "utf8ViaLatin1"]) == .utf8ViaLatin1 + ) + } + + @Test("Text written through a Latin 1 connection reads as UTF-8 under the legacy encoding") + func legacyTextIsRepaired() { + let stored = String(bytes: [0xC3, 0xA3, 0xC6, 0x92, 0xC2, 0xA1], encoding: .utf8) ?? "" + #expect(MySQLConnectionEncoding.utf8.presentedText(stored) == stored) + #expect(MySQLConnectionEncoding.utf8ViaLatin1.presentedText(stored) == "メ") + } + + @Test("A latin1 column holding Latin 1 text uses MySQL's own latin1 table") + func latin1ColumnsDecode() { + let bytes: [UInt8] = [0x69, 0x74, 0x92, 0x73, 0x20, 0x35, 0x80] + let decoded = bytes.withUnsafeBytes { MySQLCharacterSet(serverName: "latin1").decode($0) } + #expect(decoded == "it’s 5€") + } + + @Test("A binary column stays bytes and a text column stays text") + func columnKindsSurvive() { + #expect(MySQLColumnDecoding(typeRaw: 252, charsetnr: 63, characterSetName: "binary") == .bytes) + #expect(MySQLColumnDecoding(typeRaw: 253, charsetnr: 45, characterSetName: "utf8mb4") == .text(.utf8mb4)) + } + + @Test("A BLOB column reports a binary type name, so the grid does not search it as text") + func blobTypeName() { + let blob = mariaDBTypeName(typeRaw: 252, flags: mysqlBinaryFlag, charsetnr: 63, length: 100) + #expect(blob == "BLOB") + #expect(mariaDBTypeName(typeRaw: 254, flags: mysqlBinaryFlag, charsetnr: 63, length: 16) == "BINARY") + #expect(mariaDBTypeName(typeRaw: 252, flags: 0, charsetnr: 45, length: 100) == "TEXT") + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift index c447a9d329..a82e087ba7 100644 --- a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLConnectionStringTests.swift @@ -34,6 +34,11 @@ struct PostgreSQLConnectionStringTests { #expect(connStr.contains("sslmode='disable'")) } + @Test("the session is pinned to UTF8 in the startup packet, whatever the database's encoding") + func pinsClientEncoding() { + #expect(build(ssl: .disabled).contains("client_encoding='UTF8'")) + } + @Test("a client certificate and key reach libpq as sslcert and sslkey") func clientCertificateIsSent() { let certPath = temporaryFile() diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLCopyStateTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLCopyStateTests.swift new file mode 100644 index 0000000000..65e27e0c65 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLCopyStateTests.swift @@ -0,0 +1,61 @@ +import CLibPQ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("PostgreSQL COPY state") +struct PostgreSQLCopyStateTests { + @Test("Every libpq COPY status maps to its direction") + func copyStatusMapping() { + #expect(LibPQCopyState.direction(of: PGRES_COPY_IN) == .copyIn) + #expect(LibPQCopyState.direction(of: PGRES_COPY_OUT) == .copyOut) + #expect(LibPQCopyState.direction(of: PGRES_COPY_BOTH) == .copyBoth) + } + + @Test("A result that is not a COPY maps to no direction") + func nonCopyStatusMapping() { + #expect(LibPQCopyState.direction(of: PGRES_COMMAND_OK) == nil) + #expect(LibPQCopyState.direction(of: PGRES_TUPLES_OK) == nil) + #expect(LibPQCopyState.direction(of: PGRES_SINGLE_TUPLE) == nil) + #expect(LibPQCopyState.direction(of: PGRES_FATAL_ERROR) == nil) + #expect(LibPQCopyState.direction(of: PGRES_EMPTY_QUERY) == nil) + } + + @Test("Every direction explains itself and names the COPY form it rejects") + func unsupportedMessages() { + #expect(LibPQCopyDirection.copyIn.unsupportedMessage.contains("COPY FROM STDIN")) + #expect(LibPQCopyDirection.copyIn.unsupportedMessage.contains("no rows were sent")) + #expect(LibPQCopyDirection.copyOut.unsupportedMessage.contains("COPY TO STDOUT")) + #expect(!LibPQCopyDirection.copyBoth.unsupportedMessage.isEmpty) + let messages = Set([LibPQCopyDirection.copyIn, .copyOut, .copyBoth].map(\.unsupportedMessage)) + #expect(messages.count == 3) + } + + @Test("A COPY behind an earlier statement is ended and reported, never discarded in silence") + func copyAfterAnotherStatementIsReported() { + let copy = LibPQCopy(direction: .copyIn, format: .textual) + var pending: [LibPQPendingResult] = [.completed, .copy(copy)] + var ended: [LibPQCopy] = [] + let outcome = LibPQPendingResultDrain.drain( + nextResult: { pending.isEmpty ? nil : pending.removeFirst() }, + endCopy: { ended.append($0) } + ) + #expect(ended == [copy]) + #expect(outcome.abandonedCopy == copy) + } + + @Test("A COPY libpq never leaves stops the drain and marks the connection unusable") + func stuckCopyTerminates() { + var reads = 0 + let outcome = LibPQPendingResultDrain.drain( + nextResult: { + reads += 1 + return .copy(LibPQCopy(direction: .copyOut, format: .textual)) + }, + endCopy: { _ in } + ) + #expect(outcome.stuckInCopy == LibPQCopy(direction: .copyOut, format: .textual)) + #expect(outcome.leavesConnectionUnusable) + #expect(reads == 2) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLForeignKeyQueryTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLForeignKeyQueryTests.swift new file mode 100644 index 0000000000..73a17cd9cc --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLForeignKeyQueryTests.swift @@ -0,0 +1,68 @@ +import Foundation +@testable import TableProMobile +import TableProPluginKit +import Testing + +@Suite("PostgreSQL foreign key query") +struct PostgreSQLForeignKeyQueryTests { + private func row(_ side: String, _ attributeNumber: Int, _ attributeName: String) -> [String?] { + var cells: [String?] = Array(repeating: nil, count: PostgreSQLCatalogForeignKeys.Column.allCases.count) + cells[PostgreSQLCatalogForeignKeys.Column.constraintIdentity.rawValue] = "42" + cells[PostgreSQLCatalogForeignKeys.Column.constraintName.rawValue] = "fk_xy" + cells[PostgreSQLCatalogForeignKeys.Column.referencedSchema.rawValue] = "sales" + cells[PostgreSQLCatalogForeignKeys.Column.referencedTable.rawValue] = "parent" + cells[PostgreSQLCatalogForeignKeys.Column.deleteAction.rawValue] = "c" + cells[PostgreSQLCatalogForeignKeys.Column.updateAction.rawValue] = "a" + cells[PostgreSQLCatalogForeignKeys.Column.sourceKeys.rawValue] = "{2,3}" + cells[PostgreSQLCatalogForeignKeys.Column.referencedKeys.rawValue] = "{1,2}" + cells[PostgreSQLCatalogForeignKeys.Column.side.rawValue] = side + cells[PostgreSQLCatalogForeignKeys.Column.attributeNumber.rawValue] = String(attributeNumber) + cells[PostgreSQLCatalogForeignKeys.Column.attributeName.rawValue] = attributeName + return cells + } + + @Test("a composite key decodes to one pair per column, in key order") + func decodesOnePairPerColumn() { + let rows = [row("s", 2, "a"), row("s", 3, "b"), row("r", 1, "x"), row("r", 2, "y")] + + let keys = PostgreSQLCatalogForeignKeys.foreignKeys(from: rows) + + #expect(keys.map(\.column) == ["a", "b"]) + #expect(keys.map(\.referencedColumn) == ["x", "y"]) + #expect(keys.allSatisfy { $0.referencedSchema == "sales" }) + #expect(keys.allSatisfy { $0.onDelete == "CASCADE" && $0.onUpdate == "NO ACTION" }) + } + + @Test("the constraint is found by the table's own schema and name") + func filtersOnSchemaAndTable() { + let query = PostgreSQLDriver.foreignKeysQuery(schema: "sales", table: "orders", serverVersionNumber: 170_011) + + #expect(query.contains("ns.nspname = 'sales'")) + #expect(query.contains("cl.relname = 'orders'")) + } + + @Test("quotes in the schema and table names are doubled") + func escapesQuotes() { + let query = PostgreSQLDriver.foreignKeysQuery(schema: "o'brien", table: "it's", serverVersionNumber: 170_011) + + #expect(query.contains("ns.nspname = 'o''brien'")) + #expect(query.contains("cl.relname = 'it''s'")) + } + + @Test("a backslash in the schema name is quoted as an E-string") + func escapesBackslash() { + let query = PostgreSQLDriver.foreignKeysQuery(schema: #"a\b"#, table: "t", serverVersionNumber: 170_011) + + #expect(query.contains(#"ns.nspname = E'a\\b'"#)) + } + + @Test("a Redshift server never receives the PostgreSQL 11 clone filter") + func redshiftKeepsPortableQuery() { + let redshift = PostgreSQLDriver.foreignKeysQuery(schema: "public", table: "t", serverVersionNumber: 80_002) + let modern = PostgreSQLDriver.foreignKeysQuery(schema: "public", table: "t", serverVersionNumber: 170_011) + + #expect(!redshift.contains("conparentid")) + #expect(!redshift.contains("generate_subscripts")) + #expect(modern.contains("conparentid")) + } +} diff --git a/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift b/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift index 22e6543edc..7d72893bff 100644 --- a/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift +++ b/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift @@ -18,6 +18,9 @@ struct SQLBuilderDefaultValuesTests { #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "`t`", for: .tidb) == "INSERT INTO `t` () VALUES ()") #expect(SQLBuilder.quoteIdentifier("a`b", for: .tidb) == "`a``b`") + #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "`t`", for: .oceanbase) + == "INSERT INTO `t` () VALUES ()") + #expect(SQLBuilder.speaksMySQLDialect(.oceanbase)) } @Test("Databend is not a MySQL dialect on iOS") diff --git a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift index 390e9c2428..7f5caf1e58 100644 --- a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift +++ b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift @@ -9,9 +9,11 @@ struct MySQLVariantSupportTests { @Test("TiDB is offered and supported, Databend is neither") func offeredTypes() { #expect(DatabaseType.mobileSupportedTypes.contains(.tidb)) + #expect(DatabaseType.mobileSupportedTypes.contains(.oceanbase)) #expect(!DatabaseType.mobileSupportedTypes.contains(.databend)) let supported = IOSDriverFactory().supportedTypes() #expect(supported.contains(.tidb)) + #expect(supported.contains(.oceanbase)) #expect(!supported.contains(.databend)) } @@ -21,11 +23,14 @@ struct MySQLVariantSupportTests { #expect(DatabaseType.tidb.mobileDisplayName == "TiDB") #expect(DatabaseType.databend.defaultPort == "3307") #expect(DatabaseType.databend.mobileDisplayName == "Databend") + #expect(DatabaseType.oceanbase.defaultPort == "2881") + #expect(DatabaseType.oceanbase.mobileDisplayName == "OceanBase") } @Test("TiDB takes tabular inserts from Shortcuts, Databend does not") func tabularInsert() { #expect(IntentDatabaseSession.supportsTabularInsert(.tidb)) + #expect(IntentDatabaseSession.supportsTabularInsert(.oceanbase)) #expect(!IntentDatabaseSession.supportsTabularInsert(.databend)) } @@ -36,4 +41,12 @@ struct MySQLVariantSupportTests { let mysql = try #require(driver as? MySQLDriver) #expect(mysql.databaseType == .tidb) } + + @Test("OceanBase routes to the MySQL driver with its own type") + func oceanBaseRoutesToMySQLDriver() throws { + let connection = DatabaseConnection(name: "o", type: .oceanbase, host: "127.0.0.1", port: 2_881) + let driver = try IOSDriverFactory().createDriver(for: connection, password: nil) + let mysql = try #require(driver as? MySQLDriver) + #expect(mysql.databaseType == .oceanbase) + } } diff --git a/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift b/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift index f7d65f1ec6..12206f2c12 100644 --- a/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift +++ b/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift @@ -42,6 +42,7 @@ struct SQLDialectParityTests { (.mysql, .collationDefined), (.mariadb, .collationDefined), (.tidb, .collationDefined), + (.oceanbase, .collationDefined), (.mssql, .collationDefined), (.postgresql, .ilikeOperator), (.duckdb, .ilikeOperator), diff --git a/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/Contents.json new file mode 100644 index 0000000000..eb0b7fe6e2 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "oceanbase.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg new file mode 100644 index 0000000000..a423b01f5c --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg @@ -0,0 +1 @@ + diff --git a/TableProMobile/TableProWidget/Assets.xcassets/weaviate-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/weaviate-icon.imageset/Contents.json new file mode 100644 index 0000000000..fcfa2ae760 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/weaviate-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "weaviate.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/weaviate-icon.imageset/weaviate.svg b/TableProMobile/TableProWidget/Assets.xcassets/weaviate-icon.imageset/weaviate.svg new file mode 100644 index 0000000000..fafccd810f --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/weaviate-icon.imageset/weaviate.svg @@ -0,0 +1 @@ +Weaviate diff --git a/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift b/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift index c5e91110f7..b6d0fae762 100644 --- a/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift +++ b/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift @@ -7,6 +7,7 @@ enum DatabaseTypeStyle { case "MariaDB": return "mariadb-icon" case "TiDB": return "tidb-icon" case "Databend": return "databend-icon" + case "OceanBase": return "oceanbase-icon" case "PostgreSQL": return "postgresql-icon" case "Redshift": return "redshift-icon" case "SQLite": return "sqlite-icon" @@ -22,6 +23,7 @@ enum DatabaseTypeStyle { case "DynamoDB": return "dynamodb-icon" case "BigQuery": return "bigquery-icon" case "Spanner": return "spanner-icon" + case "Weaviate": return "weaviate-icon" default: return "externaldrive" } } @@ -46,6 +48,7 @@ enum DatabaseTypeStyle { case "MySQL", "MariaDB": return .orange case "TiDB": return .red case "Databend": return .blue + case "OceanBase": return .blue case "PostgreSQL", "Redshift": return .blue case "SQLite": return .green case "Redis": return .red diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index 64beabbf6d..5612addee1 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -52,6 +52,30 @@ targets: - ../Plugins/RedisDriverPlugin/RedisAuthCommand.swift - ../Plugins/RedisDriverPlugin/RedisConnectProbe.swift - ../Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift + # MySQL session character set and result decoding the iOS driver shares with the macOS plugin. + - ../Plugins/MySQLDriverPlugin/DatabendResultShape.swift + - ../Plugins/MySQLDriverPlugin/GeometryWKBParser.swift + - ../Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift + - ../Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift + - ../Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift + - ../Plugins/MySQLDriverPlugin/MySQLCharacterSet.swift + - ../Plugins/MySQLDriverPlugin/MySQLColumnDecoding.swift + - ../Plugins/MySQLDriverPlugin/MySQLConnectionEncoding.swift + - ../Plugins/MySQLDriverPlugin/MySQLLatin1.swift + - ../Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift + # Maintenance operations and identifier quoting the iOS flavor switch shares with the macOS plugin. + - ../Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift + - ../Plugins/MySQLDriverPlugin/DatabendCatalog.swift + - ../Plugins/MySQLDriverPlugin/MySQLMaintenance.swift + # Foreign key catalog read the iOS PostgreSQL driver shares with the macOS plugin, plus the + # single owner of the plugin's literal quoting that it builds its SQL with, and the version + # gate that owner projects against. + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogForeignKeys.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift + # libpq COPY handling the iOS PostgreSQL driver shares with the macOS plugin. + - ../Plugins/PostgreSQLDriverPlugin/LibPQPendingResultDrain.swift + - ../Plugins/PostgreSQLDriverPlugin/LibPQCopyState.swift configFiles: Debug: ../Configs/Version-iOS.xcconfig Release: ../Configs/Version-iOS.xcconfig diff --git a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift index 68bf261328..9464ec8a9f 100644 --- a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift +++ b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift @@ -108,6 +108,7 @@ struct CloudSQLProxyModelTests { #expect(!DatabaseType.mariadb.supportsCloudSQLProxy) #expect(!DatabaseType.tidb.supportsCloudSQLProxy) #expect(!DatabaseType.databend.supportsCloudSQLProxy) + #expect(!DatabaseType.oceanbase.supportsCloudSQLProxy) #expect(!DatabaseType.sqlite.supportsCloudSQLProxy) #expect(!DatabaseType.mongodb.supportsCloudSQLProxy) } diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 64cdfec3bf..a9a4b10742 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -15,6 +15,7 @@ import Testing final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sendable { let connection: DatabaseConnection var status: ConnectionStatus = .connected + var hasLostConnection = false var serverVersion: String? { nil } var currentSchema: String? diff --git a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift index 1f85196441..5ece352754 100644 --- a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift @@ -23,7 +23,7 @@ struct AnyChangeManagerTests { #expect(wrapper.hasChanges == false) - dataManager.recordCellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob") + dataManager.recordCellChange(rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob") #expect(dataManager.hasChanges == true) #expect(wrapper.hasChanges == true) @@ -47,11 +47,11 @@ struct AnyChangeManagerTests { dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, generatedColumns: []) let wrapper = AnyChangeManager(dataManager) - #expect(wrapper.isRowDeleted(0) == false) + #expect(wrapper.isRowDeleted(.existing(0)) == false) - dataManager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice"]) + dataManager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Alice"]) - #expect(wrapper.isRowDeleted(0) == true) + #expect(wrapper.isRowDeleted(.existing(0)) == true) } @Test("recordCellChange forwards to DataChangeManager") @@ -60,7 +60,7 @@ struct AnyChangeManagerTests { dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, generatedColumns: []) let wrapper = AnyChangeManager(dataManager) - wrapper.recordCellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob", originalRow: ["1", "Alice"]) + wrapper.recordCellChange(rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob", originalRow: ["1", "Alice"]) #expect(dataManager.hasChanges == true) #expect(!wrapper.rowChanges.isEmpty) @@ -89,8 +89,8 @@ struct AnyChangeManagerTests { let structureManager = StructureChangeManager() let wrapper = AnyChangeManager(structureManager) - #expect(wrapper.isRowDeleted(0) == false) - #expect(wrapper.isRowDeleted(100) == false) + #expect(wrapper.isRowDeleted(.existing(0)) == false) + #expect(wrapper.isRowDeleted(.existing(100)) == false) } @Test("StructureChangeManager wrapper: hasChanges forwards correctly when false") diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift index 865541fb4a..8ad21e2df3 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift @@ -26,7 +26,7 @@ struct DataChangeManagerClickHouseTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "status", oldValue: "pending", @@ -52,7 +52,7 @@ struct DataChangeManagerClickHouseTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index f530a83f6a..73fb6b5c4f 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -37,33 +37,33 @@ struct DataChangeManagerExtendedTests { @Test("Record row insertion sets hasChanges to true") func recordRowInsertionSetsHasChanges() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) #expect(manager.hasChanges) } @Test("Record row insertion stores values in insertedRowData") func recordRowInsertionStoresInInsertedRowData() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) let state = manager.saveState() - #expect(state.insertedRowData[5] == ["a", "b", "c"]) + #expect(state.insertedRowData[insertedID(5)] == ["a", "b", "c"]) } @Test("Record row insertion adds insert-type change with empty cellChanges") func recordRowInsertionAddsInsertChange() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) #expect(manager.changes.count == 1) #expect(manager.changes[0].type == .insert) #expect(manager.changes[0].cellChanges.isEmpty) } - @Test("Record row insertion tracks index in insertedRowIndices") + @Test("Record row insertion tracks index in insertedRowIDs") func recordRowInsertionTracksInInsertedRowIndices() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - #expect(manager.isRowInserted(5)) - #expect(!manager.isRowInserted(0)) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) + #expect(manager.isRowInserted(insertedID(5))) + #expect(!manager.isRowInserted(.existing(0))) } @Test("Record row insertion increments reloadVersion by 1") @@ -76,7 +76,7 @@ struct DataChangeManagerExtendedTests { let manager = makeManager() let before = manager.reloadVersion - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) #expect(manager.reloadVersion == before) @@ -88,7 +88,7 @@ struct DataChangeManagerExtendedTests { @Test("Record row insertion enables undo") func recordRowInsertionEnablesUndo() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) #expect(manager.canUndo) } @@ -96,20 +96,20 @@ struct DataChangeManagerExtendedTests { func recordRowInsertionClearsRedoStack() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) manager.undoManagerProvider?()?.undo() #expect(manager.canRedo) - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) #expect(!manager.canRedo) } @Test("Multiple row insertions tracked separately") func multipleRowInsertionsTrackedSeparately() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 0, values: ["a", "b", "c"]) - manager.recordRowInsertion(rowIndex: 1, values: ["d", "e", "f"]) + manager.recordRowInsertion(rowID: insertedID(0), values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(1), values: ["d", "e", "f"]) #expect(manager.changes.count == 2) #expect(manager.changes[0].type == .insert) #expect(manager.changes[1].type == .insert) @@ -120,75 +120,75 @@ struct DataChangeManagerExtendedTests { @Test("isRowDeleted returns true for deleted row, false for others") func isRowDeletedCorrectness() { let manager = makeManager() - manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) - #expect(manager.isRowDeleted(2)) - #expect(!manager.isRowDeleted(0)) + manager.recordRowDeletion(rowID: .existing(2), originalRow: ["3", "Charlie", "c@test.com"]) + #expect(manager.isRowDeleted(.existing(2))) + #expect(!manager.isRowDeleted(.existing(0))) } @Test("isRowInserted returns true for inserted row, false for others") func isRowInsertedCorrectness() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - #expect(manager.isRowInserted(5)) - #expect(!manager.isRowInserted(0)) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) + #expect(manager.isRowInserted(insertedID(5))) + #expect(!manager.isRowInserted(.existing(0))) } @Test("isCellModified returns true after edit, false for unmodified cells") func isCellModifiedTrueAfterEdit() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 0)) + #expect(manager.isCellModified(rowID: .existing(0), columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 0)) } @Test("isCellModified returns false after reverting to original value") func isCellModifiedFalseAfterRevertToOriginal() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "B", newValue: "A" ) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("getModifiedColumnsForRow returns correct set of modified columns") func getModifiedColumnsCorrectSet() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "email", + rowID: .existing(0), columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" ) - #expect(manager.getModifiedColumnsForRow(0) == [1, 2]) + #expect(manager.getModifiedColumnsForRow(.existing(0)) == [1, 2]) } @Test("getModifiedColumnsForRow returns empty set for unmodified row") func getModifiedColumnsEmptyForUnmodifiedRow() { let manager = makeManager() - #expect(manager.getModifiedColumnsForRow(99).isEmpty) + #expect(manager.getModifiedColumnsForRow(.existing(99)).isEmpty) } @Test("Cell modification cleared when row is deleted") func cellModificationClearedOnRowDeletion() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) - manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Bob", "a@test.com"]) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) - #expect(manager.getModifiedColumnsForRow(0).isEmpty) + #expect(manager.isCellModified(rowID: .existing(0), columnIndex: 1)) + manager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Bob", "a@test.com"]) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) + #expect(manager.getModifiedColumnsForRow(.existing(0)).isEmpty) } // MARK: - State Save/Restore @@ -197,7 +197,7 @@ struct DataChangeManagerExtendedTests { func saveStateCapturesChanges() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) let state = manager.saveState() @@ -208,36 +208,36 @@ struct DataChangeManagerExtendedTests { @Test("saveState captures deleted row indices") func saveStateCapturesDeletedRowIndices() { let manager = makeManager() - manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) + manager.recordRowDeletion(rowID: .existing(2), originalRow: ["3", "Charlie", "c@test.com"]) let state = manager.saveState() - #expect(state.deletedRowIndices.contains(2)) + #expect(state.deletedRowIDs.contains(.existing(2))) } @Test("saveState captures inserted row indices") func saveStateCapturesInsertedRowIndices() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 0, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(0), values: ["a", "b", "c"]) let state = manager.saveState() - #expect(state.insertedRowIndices.contains(0)) + #expect(state.insertedRowIDs.contains(insertedID(0))) } @Test("saveState captures modified cells") func saveStateCapturesModifiedCells() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) let state = manager.saveState() - #expect(state.modifiedCells[0]?.contains(1) == true) + #expect(state.modifiedCells[.existing(0)]?.contains(1) == true) } @Test("saveState captures inserted row data") func saveStateCapturesInsertedRowData() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 0, values: ["x", "y", "z"]) + manager.recordRowInsertion(rowID: insertedID(0), values: ["x", "y", "z"]) let state = manager.saveState() - #expect(state.insertedRowData[0] == ["x", "y", "z"]) + #expect(state.insertedRowData[insertedID(0)] == ["x", "y", "z"]) } @Test("saveState captures columns and primary key") @@ -252,7 +252,7 @@ struct DataChangeManagerExtendedTests { func roundTripPreservesHasChanges() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) let state = manager.saveState() @@ -265,38 +265,38 @@ struct DataChangeManagerExtendedTests { @Test("Round-trip save/restore preserves isRowDeleted") func roundTripPreservesIsRowDeleted() { let manager = makeManager() - manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) + manager.recordRowDeletion(rowID: .existing(2), originalRow: ["3", "Charlie", "c@test.com"]) let state = manager.saveState() manager.clearChanges() manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) - #expect(manager.isRowDeleted(2)) + #expect(manager.isRowDeleted(.existing(2))) } @Test("Round-trip save/restore preserves isCellModified") func roundTripPreservesIsCellModified() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) let state = manager.saveState() manager.clearChanges() manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) - #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(manager.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("Round-trip save/restore allows continued editing") func roundTripCanContinueEditing() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) let state = manager.saveState() manager.clearChanges() manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) manager.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "email", + rowID: .existing(0), columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" ) #expect(manager.changes.count == 1) @@ -318,7 +318,7 @@ struct DataChangeManagerExtendedTests { func discardChangesSetsHasChangesFalse() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.discardChanges() @@ -329,16 +329,16 @@ struct DataChangeManagerExtendedTests { func discardChangesClearsAllTrackedChanges() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - manager.recordRowDeletion(rowIndex: 1, originalRow: ["2", "Charlie", "c@test.com"]) - manager.recordRowInsertion(rowIndex: 5, values: ["x", "y", "z"]) + manager.recordRowDeletion(rowID: .existing(1), originalRow: ["2", "Charlie", "c@test.com"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["x", "y", "z"]) manager.discardChanges() #expect(manager.changes.isEmpty) - #expect(!manager.isRowDeleted(1)) - #expect(!manager.isRowInserted(5)) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!manager.isRowDeleted(.existing(1))) + #expect(!manager.isRowInserted(insertedID(5))) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("only clearChangesAndUndoHistory drops the undo stack") @@ -346,7 +346,7 @@ struct DataChangeManagerExtendedTests { // discardChanges preserves undo/redo let manager1 = makeManager() manager1.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager1.undoManagerProvider?()?.undo() @@ -360,7 +360,7 @@ struct DataChangeManagerExtendedTests { /// `clearChanges` wiped undo, which would make the two indistinguishable. let manager2 = makeManager() manager2.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager2.undoManagerProvider?()?.undo() @@ -377,7 +377,7 @@ struct DataChangeManagerExtendedTests { func discardChangesIncrementsReloadVersion() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) let before = manager.reloadVersion @@ -389,16 +389,16 @@ struct DataChangeManagerExtendedTests { func discardChangesAllQueryMethodsReturnDefaults() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - manager.recordRowDeletion(rowIndex: 1, originalRow: ["2", "Charlie", "c@test.com"]) - manager.recordRowInsertion(rowIndex: 5, values: ["x", "y", "z"]) + manager.recordRowDeletion(rowID: .existing(1), originalRow: ["2", "Charlie", "c@test.com"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["x", "y", "z"]) manager.discardChanges() - #expect(!manager.isRowDeleted(1)) - #expect(!manager.isRowInserted(5)) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) - #expect(manager.getModifiedColumnsForRow(0).isEmpty) + #expect(!manager.isRowDeleted(.existing(1))) + #expect(!manager.isRowInserted(insertedID(5))) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) + #expect(manager.getModifiedColumnsForRow(.existing(0)).isEmpty) #expect(manager.getOriginalValues().isEmpty) } @@ -408,11 +408,11 @@ struct DataChangeManagerExtendedTests { func multipleSequentialUndos() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.recordCellChange( - rowIndex: 1, columnIndex: 1, columnName: "name", + rowID: .existing(1), columnIndex: 1, columnName: "name", oldValue: "Charlie", newValue: "Dave" ) manager.undoManagerProvider?()?.undo() @@ -425,7 +425,7 @@ struct DataChangeManagerExtendedTests { func undoCellEditThenRedoRestoresChange() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) manager.undoManagerProvider?()?.undo() @@ -435,51 +435,51 @@ struct DataChangeManagerExtendedTests { #expect(manager.changes[0].cellChanges[0].newValue == "B") } - @Test("Undo row insertion removes from insertedRowIndices") + @Test("Undo row insertion removes from insertedRowIDs") func undoRowInsertionRemovesFromIndices() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) manager.undoManagerProvider?()?.undo() - #expect(!manager.isRowInserted(5)) + #expect(!manager.isRowInserted(insertedID(5))) } - @Test("Undo row deletion removes from deletedRowIndices") + @Test("Undo row deletion removes from deletedRowIDs") func undoRowDeletionRemovesFromIndices() { let manager = makeManager() - manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) + manager.recordRowDeletion(rowID: .existing(2), originalRow: ["3", "Charlie", "c@test.com"]) manager.undoManagerProvider?()?.undo() - #expect(!manager.isRowDeleted(2)) + #expect(!manager.isRowDeleted(.existing(2))) } @Test("Undo row insertion then redo re-inserts the row") func undoRowInsertionThenRedoReInserts() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) manager.undoManagerProvider?()?.undo() - #expect(!manager.isRowInserted(5)) + #expect(!manager.isRowInserted(insertedID(5))) manager.undoManagerProvider?()?.redo() - #expect(manager.isRowInserted(5)) + #expect(manager.isRowInserted(insertedID(5))) } @Test("Undo row deletion then redo re-deletes the row") func undoRowDeletionThenRedoReDeletes() { let manager = makeManager() - manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) + manager.recordRowDeletion(rowID: .existing(2), originalRow: ["3", "Charlie", "c@test.com"]) manager.undoManagerProvider?()?.undo() - #expect(!manager.isRowDeleted(2)) + #expect(!manager.isRowDeleted(.existing(2))) manager.undoManagerProvider?()?.redo() - #expect(manager.isRowDeleted(2)) + #expect(manager.isRowDeleted(.existing(2))) } @Test("Full undo/redo chain: edit A, edit B, undo B, undo A, redo A, redo B") func fullUndoRedoChainABUndoBUndoARedoARedoB() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "A2" ) manager.recordCellChange( - rowIndex: 1, columnIndex: 1, columnName: "name", + rowID: .existing(1), columnIndex: 1, columnName: "name", oldValue: "Bob", newValue: "B2" ) #expect(manager.changes.count == 2) @@ -503,7 +503,7 @@ struct DataChangeManagerExtendedTests { var captured: UndoResult? manager.onUndoApplied = { captured = $0 } manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.undoManagerProvider?()?.undo() @@ -517,7 +517,7 @@ struct DataChangeManagerExtendedTests { let manager = makeManager() var captured: UndoResult? manager.onUndoApplied = { captured = $0 } - manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) manager.undoManagerProvider?()?.undo() #expect(captured != nil) #expect(captured?.needsRowRemoval == true) @@ -528,7 +528,7 @@ struct DataChangeManagerExtendedTests { let manager = makeManager() var captured: UndoResult? manager.onUndoApplied = { captured = $0 } - manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice"]) + manager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Alice"]) manager.undoManagerProvider?()?.undo() #expect(captured != nil) #expect(captured?.needsRowRestore == true) @@ -563,56 +563,56 @@ struct DataChangeManagerExtendedTests { func editThenDeleteSameRow() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Bob", "a@test.com"]) + manager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Bob", "a@test.com"]) #expect(manager.changes.count == 1) #expect(manager.changes[0].type == .delete) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("Insert then edit updates insertedRowData") func insertThenEditUpdatesInsertedRowData() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 0, values: ["", "", ""]) + manager.recordRowInsertion(rowID: insertedID(0), values: ["", "", ""]) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: insertedID(0), columnIndex: 1, columnName: "name", oldValue: nil, newValue: "hello" ) #expect(manager.changes.count == 1) #expect(manager.changes[0].type == .insert) let state = manager.saveState() - #expect(state.insertedRowData[0]?[1] == "hello") + #expect(state.insertedRowData[insertedID(0)]?[1] == "hello") } @Test("Insert then edit then undo reverts inserted row cell data") func insertThenEditThenUndoRevertsCell() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 0, values: [nil, nil, nil]) + manager.recordRowInsertion(rowID: insertedID(0), values: [nil, nil, nil]) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: insertedID(0), columnIndex: 1, columnName: "name", oldValue: nil, newValue: "hello" ) manager.undoManagerProvider?()?.undo() let state = manager.saveState() /// `.null`, not a Swift nil. An inserted row holds an explicit SQL NULL for a cell with no /// value, so undoing an edit restores `.null` rather than removing the entry. - #expect(state.insertedRowData[0]?[1] == .null) + #expect(state.insertedRowData[insertedID(0)]?[1] == .null) } @Test("Edit multiple cells in same row all tracked") func editMultipleCellsSameRowAllTracked() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "email", + rowID: .existing(0), columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" ) - #expect(manager.getModifiedColumnsForRow(0) == [1, 2]) + #expect(manager.getModifiedColumnsForRow(.existing(0)) == [1, 2]) #expect(manager.changes[0].cellChanges.count == 2) } @@ -620,79 +620,79 @@ struct DataChangeManagerExtendedTests { func editMultipleCellsRevertOneOnlyRevertedRemoved() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "email", + rowID: .existing(0), columnIndex: 2, columnName: "email", oldValue: "C", newValue: "D" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "B", newValue: "A" ) - #expect(manager.getModifiedColumnsForRow(0) == [2]) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(manager.getModifiedColumnsForRow(.existing(0)) == [2]) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("Batch deletion clears prior edits for deleted rows") func batchDeletionClearsPriorEdits() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "A2" ) manager.recordCellChange( - rowIndex: 1, columnIndex: 1, columnName: "name", + rowID: .existing(1), columnIndex: 1, columnName: "name", oldValue: "Bob", newValue: "B2" ) manager.recordCellChange( - rowIndex: 2, columnIndex: 1, columnName: "name", + rowID: .existing(2), columnIndex: 1, columnName: "name", oldValue: "Charlie", newValue: "C2" ) manager.recordBatchRowDeletion(rows: [ - (rowIndex: 0, originalRow: ["1", "A2", "a@test.com"]), - (rowIndex: 1, originalRow: ["2", "B2", "b@test.com"]) + (rowID: .existing(0), originalRow: ["1", "A2", "a@test.com"]), + (rowID: .existing(1), originalRow: ["2", "B2", "b@test.com"]) ]) - #expect(manager.isRowDeleted(0)) - #expect(manager.isRowDeleted(1)) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) - #expect(!manager.isCellModified(rowIndex: 1, columnIndex: 1)) - #expect(manager.isCellModified(rowIndex: 2, columnIndex: 1)) + #expect(manager.isRowDeleted(.existing(0))) + #expect(manager.isRowDeleted(.existing(1))) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(1), columnIndex: 1)) + #expect(manager.isCellModified(rowID: .existing(2), columnIndex: 1)) } @Test("Undo batch deletion restores all rows") func undoBatchDeletionRestoresAllRows() { let manager = makeManager() manager.recordBatchRowDeletion(rows: [ - (rowIndex: 0, originalRow: ["1", "Alice", "a@test.com"]), - (rowIndex: 1, originalRow: ["2", "Bob", "b@test.com"]), - (rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) + (rowID: .existing(0), originalRow: ["1", "Alice", "a@test.com"]), + (rowID: .existing(1), originalRow: ["2", "Bob", "b@test.com"]), + (rowID: .existing(2), originalRow: ["3", "Charlie", "c@test.com"]) ]) manager.undoManagerProvider?()?.undo() - #expect(!manager.isRowDeleted(0)) - #expect(!manager.isRowDeleted(1)) - #expect(!manager.isRowDeleted(2)) + #expect(!manager.isRowDeleted(.existing(0))) + #expect(!manager.isRowDeleted(.existing(1))) + #expect(!manager.isRowDeleted(.existing(2))) } @Test("getOriginalValues returns correct data for edits") func getOriginalValuesReturnsCorrectData() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) manager.recordCellChange( - rowIndex: 1, columnIndex: 2, columnName: "email", + rowID: .existing(1), columnIndex: 2, columnName: "email", oldValue: "C", newValue: "D" ) - manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) + manager.recordRowDeletion(rowID: .existing(2), originalRow: ["3", "Charlie", "c@test.com"]) let originals = manager.getOriginalValues() #expect(originals.count == 2) - let first = originals.first { $0.rowIndex == 0 } + let first = originals.first { $0.rowID == .existing(0) } #expect(first?.columnIndex == 1) #expect(first?.value == "A") - let second = originals.first { $0.rowIndex == 1 } + let second = originals.first { $0.rowID == .existing(1) } #expect(second?.columnIndex == 2) #expect(second?.value == "C") } @@ -702,8 +702,8 @@ struct DataChangeManagerExtendedTests { @Test("Recording deletion for already-deleted row is idempotent") func recordDeletionForAlreadyDeletedRow() { let manager = makeManager() - manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice", "a@test.com"]) - manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice", "a@test.com"]) + manager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Alice", "a@test.com"]) + manager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Alice", "a@test.com"]) #expect(manager.changes.count == 1) } @@ -725,12 +725,12 @@ struct DataChangeManagerExtendedTests { @Test("Concurrent insertions at different indices all tracked") func concurrentInsertionsAtDifferentIndices() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 0, values: ["a", "b", "c"]) - manager.recordRowInsertion(rowIndex: 5, values: ["d", "e", "f"]) - manager.recordRowInsertion(rowIndex: 10, values: ["g", "h", "i"]) - #expect(manager.isRowInserted(0)) - #expect(manager.isRowInserted(5)) - #expect(manager.isRowInserted(10)) + manager.recordRowInsertion(rowID: insertedID(0), values: ["a", "b", "c"]) + manager.recordRowInsertion(rowID: insertedID(5), values: ["d", "e", "f"]) + manager.recordRowInsertion(rowID: insertedID(10), values: ["g", "h", "i"]) + #expect(manager.isRowInserted(insertedID(0))) + #expect(manager.isRowInserted(insertedID(5))) + #expect(manager.isRowInserted(insertedID(10))) #expect(manager.changes.count == 3) } @@ -738,7 +738,7 @@ struct DataChangeManagerExtendedTests { func recordCellChangeNilToNilIsNoOp() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: nil, newValue: nil ) #expect(!manager.hasChanges) @@ -750,10 +750,10 @@ struct DataChangeManagerExtendedTests { func invariantModifiedCellsConsistentWithChangesAfterEdit() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(manager.isCellModified(rowID: .existing(0), columnIndex: 1)) #expect(manager.changes[0].cellChanges.contains { $0.columnIndex == 1 }) } @@ -761,24 +761,24 @@ struct DataChangeManagerExtendedTests { func invariantModifiedCellsClearedWhenAllReverted() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "email", + rowID: .existing(0), columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Bob", newValue: "Alice" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "email", + rowID: .existing(0), columnIndex: 2, columnName: "email", oldValue: "b@test.com", newValue: "a@test.com" ) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 2)) - #expect(manager.getModifiedColumnsForRow(0).isEmpty) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 2)) + #expect(manager.getModifiedColumnsForRow(.existing(0)).isEmpty) #expect(manager.changes.isEmpty) } @@ -786,28 +786,28 @@ struct DataChangeManagerExtendedTests { func invariantAfterUndoModifiedCellsMatchChanges() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "email", + rowID: .existing(0), columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" ) manager.undoManagerProvider?()?.undo() - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 2)) - #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 2)) + #expect(manager.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("After redo, modifiedCells matches restored changes") func invariantAfterRedoModifiedCellsMatchChanges() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.undoManagerProvider?()?.undo() manager.undoManagerProvider?()?.redo() - #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(manager.isCellModified(rowID: .existing(0), columnIndex: 1)) #expect(!manager.changes.isEmpty) } @@ -815,15 +815,15 @@ struct DataChangeManagerExtendedTests { func editUndoRedoUndoCollapses() { let manager = makeManager() manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) manager.undoManagerProvider?()?.undo() manager.undoManagerProvider?()?.redo() - #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(manager.isCellModified(rowID: .existing(0), columnIndex: 1)) manager.undoManagerProvider?()?.undo() - #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) #expect(manager.changes.isEmpty) #expect(!manager.hasChanges) } @@ -831,14 +831,18 @@ struct DataChangeManagerExtendedTests { @Test("Inserted row edit consistency between changes and insertedRowData") func invariantInsertedRowEditConsistency() { let manager = makeManager() - manager.recordRowInsertion(rowIndex: 0, values: ["", "", ""]) + manager.recordRowInsertion(rowID: insertedID(0), values: ["", "", ""]) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: insertedID(0), columnIndex: 1, columnName: "name", oldValue: nil, newValue: "hello" ) let cellChange = manager.changes[0].cellChanges.first { $0.columnIndex == 1 } #expect(cellChange?.newValue == "hello") let state = manager.saveState() - #expect(state.insertedRowData[0]?[1] == "hello") + #expect(state.insertedRowData[insertedID(0)]?[1] == "hello") } } + +private func insertedID(_ seed: Int) -> RowID { + .inserted(UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", seed)) ?? UUID()) +} diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift index c0c5310d27..c3bdedbd34 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift @@ -45,7 +45,7 @@ struct DataChangeManagerTests { func generateSQLThrowsWhenDialectNotConfigured() { let manager = DataChangeManager() manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -70,7 +70,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -114,7 +114,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -136,7 +136,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -145,7 +145,7 @@ struct DataChangeManagerTests { #expect(manager.changes.count == 1) #expect(manager.changes[0].type == .update) - #expect(manager.changes[0].rowIndex == 0) + #expect(manager.changes[0].rowID == .existing(0)) #expect(manager.changes[0].cellChanges.count == 1) #expect(manager.changes[0].cellChanges[0].columnName == "name") #expect(manager.changes[0].cellChanges[0].oldValue == "Alice") @@ -164,7 +164,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -187,7 +187,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -195,7 +195,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Bob", @@ -220,7 +220,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -229,7 +229,7 @@ struct DataChangeManagerTests { #expect(manager.hasChanges) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Bob", @@ -252,7 +252,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -260,7 +260,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 1, + rowID: .existing(1), columnIndex: 1, columnName: "name", oldValue: "Charlie", @@ -268,8 +268,8 @@ struct DataChangeManagerTests { ) #expect(manager.changes.count == 2) - #expect(manager.changes[0].rowIndex == 0) - #expect(manager.changes[1].rowIndex == 1) + #expect(manager.changes[0].rowID == .existing(0)) + #expect(manager.changes[1].rowID == .existing(1)) } // MARK: - Row Deletion Tests @@ -285,7 +285,7 @@ struct DataChangeManagerTests { generatedColumns: [] ) - manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice"]) + manager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Alice"]) #expect(manager.hasChanges) } @@ -302,7 +302,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -311,11 +311,11 @@ struct DataChangeManagerTests { #expect(manager.changes.count == 1) #expect(manager.changes[0].type == .update) - manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Bob"]) + manager.recordRowDeletion(rowID: .existing(0), originalRow: ["1", "Bob"]) #expect(manager.changes.count == 1) #expect(manager.changes[0].type == .delete) - #expect(manager.changes[0].rowIndex == 0) + #expect(manager.changes[0].rowID == .existing(0)) } @Test("Deleted row tracked in changes with type delete") @@ -329,11 +329,11 @@ struct DataChangeManagerTests { generatedColumns: [] ) - manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie"]) + manager.recordRowDeletion(rowID: .existing(2), originalRow: ["3", "Charlie"]) #expect(manager.changes.count == 1) #expect(manager.changes[0].type == .delete) - #expect(manager.changes[0].rowIndex == 2) + #expect(manager.changes[0].rowID == .existing(2)) #expect(manager.changes[0].originalRow == ["3", "Charlie"]) } @@ -348,10 +348,10 @@ struct DataChangeManagerTests { generatedColumns: [] ) - let rows: [(rowIndex: Int, originalRow: [PluginCellValue])] = [ - (rowIndex: 0, originalRow: [.text("1"), .text("Alice")]), - (rowIndex: 1, originalRow: [.text("2"), .text("Bob")]), - (rowIndex: 2, originalRow: [.text("3"), .text("Charlie")]) + let rows: [(rowID: RowID, originalRow: [PluginCellValue])] = [ + (rowID: .existing(0), originalRow: [.text("1"), .text("Alice")]), + (rowID: .existing(1), originalRow: [.text("2"), .text("Bob")]), + (rowID: .existing(2), originalRow: [.text("3"), .text("Charlie")]) ] manager.recordBatchRowDeletion(rows: rows) @@ -375,13 +375,13 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - manager.recordRowDeletion(rowIndex: 1, originalRow: ["2", "Charlie"]) + manager.recordRowDeletion(rowID: .existing(1), originalRow: ["2", "Charlie"]) manager.clearChanges() @@ -402,7 +402,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -429,7 +429,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -451,7 +451,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -477,7 +477,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -501,7 +501,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -512,7 +512,7 @@ struct DataChangeManagerTests { #expect(manager.canRedo) manager.recordCellChange( - rowIndex: 1, + rowID: .existing(1), columnIndex: 1, columnName: "name", oldValue: "Charlie", @@ -551,7 +551,7 @@ struct DataChangeManagerTests { let initialVersion = manager.reloadVersion manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -577,7 +577,7 @@ struct DataChangeManagerTests { ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -615,7 +615,7 @@ struct DataChangeManagerNonWritableTests { let manager = makeManager(generatedColumns: ["id"]) manager.recordCellChange( - rowIndex: 0, columnIndex: 0, columnName: "id", + rowID: .existing(0), columnIndex: 0, columnName: "id", oldValue: .text("1"), newValue: .text("99") ) @@ -628,7 +628,7 @@ struct DataChangeManagerNonWritableTests { let manager = makeManager(generatedColumns: ["id"]) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: .text("Alice"), newValue: .text("Bob") ) @@ -641,11 +641,11 @@ struct DataChangeManagerNonWritableTests { let manager = makeManager(generatedColumns: ["id"]) manager.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: .text("Alice"), newValue: .text("Bob") ) manager.recordCellChange( - rowIndex: 0, columnIndex: 0, columnName: "id", + rowID: .existing(0), columnIndex: 0, columnName: "id", oldValue: .text("1"), newValue: .text("99") ) diff --git a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift index cac0a254d1..0a91f01c0b 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift @@ -84,13 +84,13 @@ struct DataChangeModelsTests { ) let rowChange = RowChange( - rowIndex: 3, + rowID: .existing(3), type: .update, cellChanges: [cellChange], originalRow: ["1", "active", "user@example.com"] ) - #expect(rowChange.rowIndex == 3) + #expect(rowChange.rowID == .existing(3)) #expect(rowChange.type == .update) #expect(rowChange.cellChanges.count == 1) #expect(rowChange.cellChanges[0] == cellChange) @@ -100,7 +100,7 @@ struct DataChangeModelsTests { @Test("RowChange with empty cellChanges") func rowChangeEmptyCellChanges() { let rowChange = RowChange( - rowIndex: 0, + rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil @@ -116,8 +116,8 @@ struct DataChangeModelsTests { let pending = TabChangeSnapshot() #expect(pending.changes.isEmpty) - #expect(pending.deletedRowIndices.isEmpty) - #expect(pending.insertedRowIndices.isEmpty) + #expect(pending.deletedRowIDs.isEmpty) + #expect(pending.insertedRowIDs.isEmpty) #expect(pending.modifiedCells.isEmpty) #expect(pending.insertedRowData.isEmpty) #expect(pending.primaryKeyColumns.isEmpty) @@ -134,7 +134,7 @@ struct DataChangeModelsTests { @Test("TabChangeSnapshot hasChanges is true with changes") func tabPendingChangesHasChangesWithChanges() { let rowChange = RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update ) @@ -144,18 +144,18 @@ struct DataChangeModelsTests { #expect(pending.hasChanges) } - @Test("TabChangeSnapshot hasChanges is true with deletedRowIndices") + @Test("TabChangeSnapshot hasChanges is true with deletedRowIDs") func tabPendingChangesHasChangesWithDeleted() { var pending = TabChangeSnapshot() - pending.deletedRowIndices = [1, 2, 3] + pending.deletedRowIDs = [.existing(1), .existing(2), .existing(3)] #expect(pending.hasChanges) } - @Test("TabChangeSnapshot hasChanges is true with insertedRowIndices") + @Test("TabChangeSnapshot hasChanges is true with insertedRowIDs") func tabPendingChangesHasChangesWithInserted() { var pending = TabChangeSnapshot() - pending.insertedRowIndices = [0, 1] + pending.insertedRowIDs = [.inserted(UUID()), .inserted(UUID())] #expect(pending.hasChanges) } diff --git a/TableProTests/Core/ChangeTracking/PendingChangesReindexTests.swift b/TableProTests/Core/ChangeTracking/PendingChangesReindexTests.swift deleted file mode 100644 index c5a075f56d..0000000000 --- a/TableProTests/Core/ChangeTracking/PendingChangesReindexTests.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// PendingChangesReindexTests.swift -// TableProTests -// -// Undoing one row of a pasted batch used to renumber only two of the six things PendingChanges -// keys by row index, so the survivors' values stayed filed under their old numbers and Save wrote -// one row with another row's values while dropping the rest, reporting success either way. -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -@Suite("PendingChanges - reindexing") -struct PendingChangesReindexTests { - @Test("Undoing one row of a batch leaves the survivors' values under their new indices") - func partialBatchUndoKeepsSurvivorValues() { - var pending = PendingChanges() - pending.recordRowInsertion(rowIndex: 10, values: ["a1", "a2"]) - pending.recordRowInsertion(rowIndex: 11, values: ["b1", "b2"]) - pending.recordRowInsertion(rowIndex: 12, values: ["c1", "c2"]) - - let removed = pending.undoBatchRowInsertion(rowIndices: [10], columnCount: 2) - - #expect(removed == [["a1", "a2"]]) - #expect(pending.savedInsertedValues(forRow: 10) == ["b1", "b2"]) - #expect(pending.savedInsertedValues(forRow: 11) == ["c1", "c2"]) - #expect(pending.savedInsertedValues(forRow: 12) == nil) - #expect(pending.isRowInserted(10)) - #expect(pending.isRowInserted(11)) - #expect(!pending.isRowInserted(12)) - } - - @Test("The returned values are the whole row, not only the columns that were typed into") - func partialBatchUndoReturnsWholeRow() { - var pending = PendingChanges() - pending.recordRowInsertion(rowIndex: 0, values: [.null, .null, .null, .null]) - pending.recordCellChange( - rowIndex: 0, columnIndex: 2, columnName: "name", - oldValue: .null, newValue: "Bob" - ) - - let removed = pending.undoBatchRowInsertion(rowIndices: [0], columnCount: 4) - - #expect(removed.first?.count == 4) - #expect(removed.first?[2] == "Bob") - #expect(removed.first?[0] == .null) - } - - @Test("A restored batch comes back with the values it had, not a compacted version of them") - func undoThenRedoRoundTrips() { - var pending = PendingChanges() - pending.recordRowInsertion(rowIndex: 0, values: [.null, .null, .null]) - pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", - oldValue: .null, newValue: "Bob" - ) - - let removed = pending.undoBatchRowInsertion(rowIndices: [0], columnCount: 3) - pending.reinsertBatch(rowIndices: [0], rowValues: removed, columns: ["id", "name", "note"]) - - #expect(pending.savedInsertedValues(forRow: 0)?.count == 3) - #expect(pending.savedInsertedValues(forRow: 0)?[1] == "Bob") - } -} - -@Suite("PendingChanges - change order") -struct PendingChangesSequenceTests { - @Test("Every recorded change gets a rising sequence number") - func sequenceRises() { - var pending = PendingChanges() - pending.recordRowDeletion(rowIndex: 0, originalRow: ["a"]) - pending.recordRowInsertion(rowIndex: 1, values: ["b"]) - - let sequences = pending.changes.map(\.sequence) - #expect(sequences == sequences.sorted()) - #expect(Set(sequences).count == sequences.count) - } - - /// A cancelled change is removed by swapping the last element into its slot, so array order - /// stops matching edit order. The sequence number is what survives that. - @Test("Cancelling a change does not disturb the order of the ones that remain") - func cancellingKeepsOrder() { - var pending = PendingChanges() - pending.recordCellChange( - rowIndex: 0, columnIndex: 0, columnName: "a", - oldValue: "before", newValue: "after", originalRow: ["before"] - ) - pending.recordRowDeletion(rowIndex: 1, originalRow: ["b"]) - pending.recordRowInsertion(rowIndex: 2, values: ["c"]) - - let deleteSequence = pending.changes.first { $0.type == .delete }?.sequence - let insertSequence = pending.changes.first { $0.type == .insert }?.sequence - - pending.recordCellChange( - rowIndex: 0, columnIndex: 0, columnName: "a", - oldValue: "after", newValue: "before", originalRow: ["before"] - ) - - #expect(pending.changes.contains { $0.type == .delete && $0.sequence == deleteSequence }) - #expect(pending.changes.contains { $0.type == .insert && $0.sequence == insertSequence }) - #expect(deleteSequence.map { seq in insertSequence.map { $0 > seq } ?? false } == true) - } -} diff --git a/TableProTests/Core/ChangeTracking/PendingChangesRowIdentityTests.swift b/TableProTests/Core/ChangeTracking/PendingChangesRowIdentityTests.swift new file mode 100644 index 0000000000..7d4976a946 --- /dev/null +++ b/TableProTests/Core/ChangeTracking/PendingChangesRowIdentityTests.swift @@ -0,0 +1,136 @@ +// +// PendingChangesRowIdentityTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("PendingChanges - row identity") +struct PendingChangesRowIdentityTests { + @Test("Undoing one row of a batch leaves the survivors' values on their own rows") + func partialBatchUndoKeepsSurvivorValues() { + var pending = PendingChanges() + let first = RowID.inserted(UUID()) + let second = RowID.inserted(UUID()) + let third = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: first, values: ["a1", "a2"]) + pending.recordRowInsertion(rowID: second, values: ["b1", "b2"]) + pending.recordRowInsertion(rowID: third, values: ["c1", "c2"]) + + let removed = pending.undoBatchRowInsertion(rowIDs: [first], columnCount: 2) + + #expect(removed == [["a1", "a2"]]) + #expect(pending.savedInsertedValues(forRow: first) == nil) + #expect(pending.savedInsertedValues(forRow: second) == ["b1", "b2"]) + #expect(pending.savedInsertedValues(forRow: third) == ["c1", "c2"]) + #expect(!pending.isRowInserted(first)) + #expect(pending.isRowInserted(second)) + #expect(pending.isRowInserted(third)) + } + + @Test("Undoing an inserted row leaves another row's edits where they were") + func undoInsertionKeepsOtherRowsEdits() { + var pending = PendingChanges() + let inserted = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: inserted, values: [.null, .null]) + pending.recordCellChange( + rowID: .existing(4), columnIndex: 1, columnName: "name", + oldValue: "Ann", newValue: "Bea", originalRow: ["4", "Ann"] + ) + + _ = pending.undoRowInsertion(rowID: inserted) + + #expect(pending.isCellModified(rowID: .existing(4), columnIndex: 1)) + #expect(pending.change(forRow: .existing(4), type: .update)?.cellChanges.first?.newValue == "Bea") + #expect(pending.changes.count == 1) + } + + @Test("An existing row and an inserted row never share a key") + func existingAndInsertedRowsAreDistinct() { + var pending = PendingChanges() + let inserted = RowID.inserted(UUID()) + pending.recordRowDeletion(rowID: .existing(0), originalRow: ["1"]) + pending.recordRowInsertion(rowID: inserted, values: ["2"]) + + #expect(pending.isRowDeleted(.existing(0))) + #expect(!pending.isRowDeleted(inserted)) + #expect(pending.isRowInserted(inserted)) + #expect(!pending.isRowInserted(.existing(0))) + } + + @Test("The returned values are the whole row, not only the columns that were typed into") + func partialBatchUndoReturnsWholeRow() { + var pending = PendingChanges() + let inserted = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: inserted, values: [.null, .null, .null, .null]) + pending.recordCellChange( + rowID: inserted, columnIndex: 2, columnName: "name", + oldValue: .null, newValue: "Bob" + ) + + let removed = pending.undoBatchRowInsertion(rowIDs: [inserted], columnCount: 4) + + #expect(removed.first?.count == 4) + #expect(removed.first?[2] == "Bob") + #expect(removed.first?[0] == .null) + } + + @Test("A restored batch comes back with the values it had, not a compacted version of them") + func undoThenRedoRoundTrips() { + var pending = PendingChanges() + let inserted = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: inserted, values: [.null, .null, .null]) + pending.recordCellChange( + rowID: inserted, columnIndex: 1, columnName: "name", + oldValue: .null, newValue: "Bob" + ) + + let removed = pending.undoBatchRowInsertion(rowIDs: [inserted], columnCount: 3) + pending.reinsertBatch(rowIDs: [inserted], rowValues: removed, columns: ["id", "name", "note"]) + + #expect(pending.savedInsertedValues(forRow: inserted)?.count == 3) + #expect(pending.savedInsertedValues(forRow: inserted)?[1] == "Bob") + } +} + +@Suite("PendingChanges - change order") +struct PendingChangesSequenceTests { + @Test("Every recorded change gets a rising sequence number") + func sequenceRises() { + var pending = PendingChanges() + pending.recordRowDeletion(rowID: .existing(0), originalRow: ["a"]) + pending.recordRowInsertion(rowID: .inserted(UUID()), values: ["b"]) + + let sequences = pending.changes.map(\.sequence) + #expect(sequences == sequences.sorted()) + #expect(Set(sequences).count == sequences.count) + } + + /// A cancelled change is removed by swapping the last element into its slot, so array order + /// stops matching edit order. The sequence number is what survives that. + @Test("Cancelling a change does not disturb the order of the ones that remain") + func cancellingKeepsOrder() { + var pending = PendingChanges() + pending.recordCellChange( + rowID: .existing(0), columnIndex: 0, columnName: "a", + oldValue: "before", newValue: "after", originalRow: ["before"] + ) + pending.recordRowDeletion(rowID: .existing(1), originalRow: ["b"]) + pending.recordRowInsertion(rowID: .inserted(UUID()), values: ["c"]) + + let deleteSequence = pending.changes.first { $0.type == .delete }?.sequence + let insertSequence = pending.changes.first { $0.type == .insert }?.sequence + + pending.recordCellChange( + rowID: .existing(0), columnIndex: 0, columnName: "a", + oldValue: "after", newValue: "before", originalRow: ["before"] + ) + + #expect(pending.changes.contains { $0.type == .delete && $0.sequence == deleteSequence }) + #expect(pending.changes.contains { $0.type == .insert && $0.sequence == insertSequence }) + #expect(deleteSequence.map { seq in insertSequence.map { $0 > seq } ?? false } == true) + } +} diff --git a/TableProTests/Core/ChangeTracking/PendingChangesTests.swift b/TableProTests/Core/ChangeTracking/PendingChangesTests.swift index 0a06ad6301..3b2cb5b1a4 100644 --- a/TableProTests/Core/ChangeTracking/PendingChangesTests.swift +++ b/TableProTests/Core/ChangeTracking/PendingChangesTests.swift @@ -21,21 +21,21 @@ struct PendingChangesRecordTests { func recordCellCreatesUpdate() { var pending = PendingChanges() let recorded = pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b" ) #expect(recorded == true) #expect(pending.changes.count == 1) #expect(pending.changes[0].type == .update) - #expect(pending.isCellModified(rowIndex: 0, columnIndex: 1)) - #expect(pending.modifiedColumns(forRow: 0) == [1]) + #expect(pending.isCellModified(rowID: .existing(0), columnIndex: 1)) + #expect(pending.modifiedColumns(forRow: .existing(0)) == [1]) } @Test("No-op edit when oldValue equals newValue and no prior change") func noOpEdit() { var pending = PendingChanges() let recorded = pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "a", newValue: "a" ) #expect(recorded == false) @@ -46,23 +46,23 @@ struct PendingChangesRecordTests { func revertToOriginalCollapses() { var pending = PendingChanges() pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b" ) let collapsed = pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "b", newValue: "a" ) #expect(collapsed == true) #expect(pending.isEmpty) - #expect(!pending.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!pending.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("Recording row deletion adds delete change and marks row deleted") func recordRowDeletion() { var pending = PendingChanges() - pending.recordRowDeletion(rowIndex: 5, originalRow: ["a", "b"]) - #expect(pending.isRowDeleted(5)) + pending.recordRowDeletion(rowID: .existing(5), originalRow: ["a", "b"]) + #expect(pending.isRowDeleted(.existing(5))) #expect(pending.changes.count == 1) #expect(pending.changes[0].type == .delete) } @@ -71,40 +71,40 @@ struct PendingChangesRecordTests { func deletionRemovesUpdate() { var pending = PendingChanges() pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b" ) - pending.recordRowDeletion(rowIndex: 0, originalRow: ["a", nil]) + pending.recordRowDeletion(rowID: .existing(0), originalRow: ["a", nil]) #expect(pending.changes.count == 1) #expect(pending.changes[0].type == .delete) - #expect(!pending.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!pending.isCellModified(rowID: .existing(0), columnIndex: 1)) } @Test("Recording row insertion marks row inserted") func recordRowInsertion() { var pending = PendingChanges() - pending.recordRowInsertion(rowIndex: 3, values: ["x", "y"]) - #expect(pending.isRowInserted(3)) - #expect(pending.savedInsertedValues(forRow: 3) == ["x", "y"]) + pending.recordRowInsertion(rowID: insertedID(3), values: ["x", "y"]) + #expect(pending.isRowInserted(insertedID(3))) + #expect(pending.savedInsertedValues(forRow: insertedID(3)) == ["x", "y"]) } @Test("Double deletion of the same row is idempotent") func doubleDeletionIsIdempotent() { var pending = PendingChanges() - pending.recordRowDeletion(rowIndex: 5, originalRow: ["a"]) - pending.recordRowDeletion(rowIndex: 5, originalRow: ["a"]) + pending.recordRowDeletion(rowID: .existing(5), originalRow: ["a"]) + pending.recordRowDeletion(rowID: .existing(5), originalRow: ["a"]) #expect(pending.changes.count == 1) - #expect(pending.isRowDeleted(5)) + #expect(pending.isRowDeleted(.existing(5))) } @Test("Double insertion of the same row updates stored values without duplicating") func doubleInsertionIsIdempotent() { var pending = PendingChanges() - pending.recordRowInsertion(rowIndex: 3, values: ["x"]) - pending.recordRowInsertion(rowIndex: 3, values: ["y"]) + pending.recordRowInsertion(rowID: insertedID(3), values: ["x"]) + pending.recordRowInsertion(rowID: insertedID(3), values: ["y"]) #expect(pending.changes.count == 1) - #expect(pending.isRowInserted(3)) - #expect(pending.savedInsertedValues(forRow: 3) == ["y"]) + #expect(pending.isRowInserted(insertedID(3))) + #expect(pending.savedInsertedValues(forRow: insertedID(3)) == ["y"]) } } @@ -113,46 +113,53 @@ struct PendingChangesUndoTests { @Test("Undo row deletion clears delete state") func undoRowDeletion() { var pending = PendingChanges() - pending.recordRowDeletion(rowIndex: 0, originalRow: ["a"]) - let undone = pending.undoRowDeletion(rowIndex: 0) + pending.recordRowDeletion(rowID: .existing(0), originalRow: ["a"]) + let undone = pending.undoRowDeletion(rowID: .existing(0)) #expect(undone == true) - #expect(!pending.isRowDeleted(0)) + #expect(!pending.isRowDeleted(.existing(0))) #expect(pending.isEmpty) } - @Test("Undo row insertion shifts later inserted rows down") - func undoRowInsertionShiftsOthers() { + @Test("Undo row insertion leaves the other inserted rows and their values alone") + func undoRowInsertionLeavesOthers() { var pending = PendingChanges() - pending.recordRowInsertion(rowIndex: 1, values: ["a"]) - pending.recordRowInsertion(rowIndex: 2, values: ["b"]) - pending.recordRowInsertion(rowIndex: 3, values: ["c"]) + let first = RowID.inserted(UUID()) + let second = RowID.inserted(UUID()) + let third = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: first, values: ["a"]) + pending.recordRowInsertion(rowID: second, values: ["b"]) + pending.recordRowInsertion(rowID: third, values: ["c"]) - let undone = pending.undoRowInsertion(rowIndex: 2) + let undone = pending.undoRowInsertion(rowID: second) #expect(undone == true) - #expect(pending.isRowInserted(1)) - #expect(pending.isRowInserted(2)) - #expect(!pending.isRowInserted(3)) + #expect(pending.isRowInserted(first)) + #expect(!pending.isRowInserted(second)) + #expect(pending.isRowInserted(third)) + #expect(pending.savedInsertedValues(forRow: first) == ["a"]) + #expect(pending.savedInsertedValues(forRow: third) == ["c"]) } @Test("Undo on row that was not inserted is a no-op") func undoNonexistentInsertion() { var pending = PendingChanges() - let undone = pending.undoRowInsertion(rowIndex: 99) + let undone = pending.undoRowInsertion(rowID: .existing(99)) #expect(undone == false) } @Test("Undo batch row insertion returns saved values in order") func undoBatchRowInsertion() { var pending = PendingChanges() - pending.recordRowInsertion(rowIndex: 1, values: ["a"]) - pending.recordRowInsertion(rowIndex: 2, values: ["b"]) - pending.recordRowInsertion(rowIndex: 3, values: ["c"]) + pending.recordRowInsertion(rowID: insertedID(1), values: ["a"]) + pending.recordRowInsertion(rowID: insertedID(2), values: ["b"]) + pending.recordRowInsertion(rowID: insertedID(3), values: ["c"]) - let restored = pending.undoBatchRowInsertion(rowIndices: [1, 2, 3], columnCount: 1) + let restored = pending.undoBatchRowInsertion( + rowIDs: [insertedID(1), insertedID(2), insertedID(3)], columnCount: 1 + ) #expect(restored.count == 3) - #expect(!pending.isRowInserted(1)) - #expect(!pending.isRowInserted(2)) - #expect(!pending.isRowInserted(3)) + #expect(!pending.isRowInserted(insertedID(1))) + #expect(!pending.isRowInserted(insertedID(2))) + #expect(!pending.isRowInserted(insertedID(3))) } } @@ -162,10 +169,10 @@ struct PendingChangesReplayTests { func reapplyCellWithoutExisting() { var pending = PendingChanges() pending.reapplyCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", originalDBValue: "orig", newValue: "x", originalRow: nil ) - #expect(pending.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(pending.isCellModified(rowID: .existing(0), columnIndex: 1)) #expect(pending.changes[0].cellChanges[0].oldValue == "orig") } @@ -173,7 +180,7 @@ struct PendingChangesReplayTests { func reapplyCellPreservesOriginalDBValue() { var pending = PendingChanges() pending.reapplyCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", originalDBValue: "Alice", newValue: "Bob", originalRow: nil ) let cellChange = pending.changes[0].cellChanges[0] @@ -184,16 +191,16 @@ struct PendingChangesReplayTests { @Test("Reinsert row creates insert change with saved values") func reinsertRowFromUndo() { var pending = PendingChanges() - pending.reinsertRow(rowIndex: 2, columns: ["a", "b"], savedValues: ["x", "y"]) - #expect(pending.isRowInserted(2)) - #expect(pending.savedInsertedValues(forRow: 2) == ["x", "y"]) + pending.reinsertRow(rowID: .existing(2), columns: ["a", "b"], savedValues: ["x", "y"]) + #expect(pending.isRowInserted(.existing(2))) + #expect(pending.savedInsertedValues(forRow: .existing(2)) == ["x", "y"]) } @Test("Reapply row deletion adds delete change") func reapplyDeletion() { var pending = PendingChanges() - pending.reapplyRowDeletion(rowIndex: 0, originalRow: ["a", "b"]) - #expect(pending.isRowDeleted(0)) + pending.reapplyRowDeletion(rowID: .existing(0), originalRow: ["a", "b"]) + #expect(pending.isRowDeleted(.existing(0))) } } @@ -203,11 +210,11 @@ struct PendingChangesSnapshotTests { func snapshotRoundTrip() { var pending = PendingChanges() pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b" ) - pending.recordRowDeletion(rowIndex: 5, originalRow: ["x"]) - pending.recordRowInsertion(rowIndex: 7, values: ["new"]) + pending.recordRowDeletion(rowID: .existing(5), originalRow: ["x"]) + pending.recordRowInsertion(rowID: insertedID(7), values: ["new"]) let snapshot = pending.snapshot(primaryKeyColumns: ["id"], columns: ["id", "name"]) @@ -215,9 +222,9 @@ struct PendingChangesSnapshotTests { restored.restore(from: snapshot) #expect(restored.changes.count == pending.changes.count) - #expect(restored.isRowDeleted(5)) - #expect(restored.isRowInserted(7)) - #expect(restored.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(restored.isRowDeleted(.existing(5))) + #expect(restored.isRowInserted(insertedID(7))) + #expect(restored.isCellModified(rowID: .existing(0), columnIndex: 1)) } } @@ -227,16 +234,20 @@ struct PendingChangesLifecycleTests { func clearResets() { var pending = PendingChanges() pending.recordCellChange( - rowIndex: 0, columnIndex: 1, columnName: "name", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b" ) - pending.recordRowDeletion(rowIndex: 5, originalRow: ["x"]) + pending.recordRowDeletion(rowID: .existing(5), originalRow: ["x"]) pending.clear() #expect(pending.isEmpty) #expect(pending.changes.isEmpty) - #expect(!pending.isRowDeleted(5)) - #expect(!pending.isCellModified(rowIndex: 0, columnIndex: 1)) + #expect(!pending.isRowDeleted(.existing(5))) + #expect(!pending.isCellModified(rowID: .existing(0), columnIndex: 1)) } } + +private func insertedID(_ seed: Int) -> RowID { + .inserted(UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", seed)) ?? UUID()) +} diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBatchDeleteScaleTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBatchDeleteScaleTests.swift index f3b1e143ab..16b1458d5c 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBatchDeleteScaleTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBatchDeleteScaleTests.swift @@ -31,7 +31,7 @@ struct SQLStatementGeneratorBatchDeleteScaleTests { private func deleteChanges(count: Int, columns: [String]) -> [RowChange] { (0.. RowChange { RowChange( - rowIndex: rowIndex, + rowID: rowID, type: .update, cellChanges: [CellChange( columnIndex: columnIndex, @@ -50,21 +50,21 @@ struct SQLStatementGeneratorCompositePKTests { } private func makeMultiCellUpdateChange( - rowIndex: Int = 0, + rowID: RowID = .existing(0), cellChanges: [CellChange], originalRow: [String?] ) -> RowChange { RowChange( - rowIndex: rowIndex, + rowID: rowID, type: .update, cellChanges: cellChanges, originalRow: originalRow.map(PluginCellValue.fromOptional) ) } - private func makeDeleteChange(rowIndex: Int = 0, originalRow: [String?]) -> RowChange { + private func makeDeleteChange(rowID: RowID = .existing(0), originalRow: [String?]) -> RowChange { RowChange( - rowIndex: rowIndex, type: .delete, cellChanges: [], + rowID: rowID, type: .delete, cellChanges: [], originalRow: originalRow.map(PluginCellValue.fromOptional) ) } @@ -72,14 +72,14 @@ struct SQLStatementGeneratorCompositePKTests { private func generate( _ changes: [RowChange], generator: SQLStatementGenerator, - deletedRowIndices: Set = [], - insertedRowIndices: Set = [] + deletedRowIDs: Set = [], + insertedRowIDs: Set = [] ) -> [ParameterizedStatement] { generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs ) } @@ -189,12 +189,12 @@ struct SQLStatementGeneratorCompositePKTests { let gen = try makeGenerator() let stmts = generate([ makeUpdateChange( - rowIndex: 0, columnIndex: 2, columnName: "quantity", + rowID: .existing(0), columnIndex: 2, columnName: "quantity", oldValue: "5", newValue: "10", originalRow: ["1", "42", "5", "9.99"] ), makeUpdateChange( - rowIndex: 1, columnIndex: 2, columnName: "quantity", + rowID: .existing(1), columnIndex: 2, columnName: "quantity", oldValue: "3", newValue: "7", originalRow: ["1", "43", "3", "4.99"] ), @@ -252,9 +252,9 @@ struct SQLStatementGeneratorCompositePKTests { func deleteSingleRowCompositePK() throws { let gen = try makeGenerator() let stmts = generate( - [makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"])], + [makeDeleteChange(rowID: .existing(0), originalRow: ["1", "42", "5", "9.99"])], generator: gen, - deletedRowIndices: [0] + deletedRowIDs: [.existing(0)] ) #expect(stmts.count == 1) @@ -273,12 +273,12 @@ struct SQLStatementGeneratorCompositePKTests { let gen = try makeGenerator() let stmts = generate( [ - makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"]), - makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]), - makeDeleteChange(rowIndex: 2, originalRow: ["2", "42", "1", "7.50"]), + makeDeleteChange(rowID: .existing(0), originalRow: ["1", "42", "5", "9.99"]), + makeDeleteChange(rowID: .existing(1), originalRow: ["1", "43", "3", "4.99"]), + makeDeleteChange(rowID: .existing(2), originalRow: ["2", "42", "1", "7.50"]), ], generator: gen, - deletedRowIndices: [0, 1, 2] + deletedRowIDs: [.existing(0), .existing(1), .existing(2)] ) #expect(stmts.count == 1) @@ -294,11 +294,11 @@ struct SQLStatementGeneratorCompositePKTests { let gen = try makeGenerator(databaseType: .postgresql) let stmts = generate( [ - makeDeleteChange(rowIndex: 0, originalRow: ["1", "42", "5", "9.99"]), - makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]), + makeDeleteChange(rowID: .existing(0), originalRow: ["1", "42", "5", "9.99"]), + makeDeleteChange(rowID: .existing(1), originalRow: ["1", "43", "3", "4.99"]), ], generator: gen, - deletedRowIndices: [0, 1] + deletedRowIDs: [.existing(0), .existing(1)] ) #expect(stmts.count == 1) @@ -341,11 +341,11 @@ struct SQLStatementGeneratorCompositePKTests { ) let stmts = generate( [ - makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"]), - makeDeleteChange(rowIndex: 1, originalRow: ["2", "Jane", "jane@test.com"]), + makeDeleteChange(rowID: .existing(0), originalRow: ["1", "John", "john@test.com"]), + makeDeleteChange(rowID: .existing(1), originalRow: ["2", "Jane", "jane@test.com"]), ], generator: gen, - deletedRowIndices: [0, 1] + deletedRowIDs: [.existing(0), .existing(1)] ) #expect(stmts.count == 1) @@ -389,11 +389,11 @@ struct SQLStatementGeneratorCompositePKTests { ) let stmts = generate( [ - makeDeleteChange(rowIndex: 0, originalRow: ["2024-01-01", "hello", "info"]), - makeDeleteChange(rowIndex: 1, originalRow: ["2024-01-02", "world", "warn"]), + makeDeleteChange(rowID: .existing(0), originalRow: ["2024-01-01", "hello", "info"]), + makeDeleteChange(rowID: .existing(1), originalRow: ["2024-01-02", "world", "warn"]), ], generator: gen, - deletedRowIndices: [0, 1] + deletedRowIDs: [.existing(0), .existing(1)] ) #expect(stmts.count == 1) @@ -448,11 +448,11 @@ struct SQLStatementGeneratorCompositePKTests { let gen = try makeGenerator() let stmts = generate( [ - makeDeleteChange(rowIndex: 0, originalRow: ["1", nil, "5", "9.99"]), - makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]), + makeDeleteChange(rowID: .existing(0), originalRow: ["1", nil, "5", "9.99"]), + makeDeleteChange(rowID: .existing(1), originalRow: ["1", "43", "3", "4.99"]), ], generator: gen, - deletedRowIndices: [0, 1] + deletedRowIDs: [.existing(0), .existing(1)] ) // Row 0 has NULL PK → skipped in batch, only row 1 survives @@ -464,7 +464,7 @@ struct SQLStatementGeneratorCompositePKTests { func updateWithoutOriginalRowUsesCellChanges() throws { let gen = try makeGenerator() let change = RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 0, columnName: "order_id", oldValue: "1", newValue: "1"), @@ -485,7 +485,7 @@ struct SQLStatementGeneratorCompositePKTests { func updateWithoutOriginalRowMissingPKSkipped() throws { let gen = try makeGenerator() let change = RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 2, columnName: "quantity", oldValue: "5", newValue: "10"), @@ -502,19 +502,19 @@ struct SQLStatementGeneratorCompositePKTests { func mixedOperationsCompositePK() throws { let gen = try makeGenerator() - let insertChange = RowChange(rowIndex: 3, type: .insert, cellChanges: []) + let insertChange = RowChange(rowID: .existing(3), type: .insert, cellChanges: []) let updateChange = makeUpdateChange( - rowIndex: 0, columnIndex: 2, columnName: "quantity", + rowID: .existing(0), columnIndex: 2, columnName: "quantity", oldValue: "5", newValue: "10", originalRow: ["1", "42", "5", "9.99"] ) - let deleteChange = makeDeleteChange(rowIndex: 1, originalRow: ["1", "43", "3", "4.99"]) + let deleteChange = makeDeleteChange(rowID: .existing(1), originalRow: ["1", "43", "3", "4.99"]) let stmts = gen.generateStatements( from: [insertChange, updateChange, deleteChange], - insertedRowData: [3: ["2", "99", "1", "5.00"]], - deletedRowIndices: [1], - insertedRowIndices: [3] + insertedRowData: [.existing(3): ["2", "99", "1", "5.00"]], + deletedRowIDs: [.existing(1)], + insertedRowIDs: [.existing(3)] ) // INSERT + UPDATE + DELETE = 3 statements diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift index 79a45d4837..2435f22728 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift @@ -30,14 +30,14 @@ struct SQLStatementGeneratorGeneratedColumnTests { func insertFromStoredDataOmitsGeneratedColumn() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, - insertedRowData: [0: ["1", "John", "John Doe"]], - deletedRowIndices: [], - insertedRowIndices: [0] + insertedRowData: [.existing(0): ["1", "John", "John Doe"]], + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) let statement = try #require(statements.first) @@ -51,7 +51,7 @@ struct SQLStatementGeneratorGeneratedColumnTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .insert, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: .null, newValue: "John"), @@ -69,8 +69,8 @@ struct SQLStatementGeneratorGeneratedColumnTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) let statement = try #require(statements.first) @@ -83,7 +83,7 @@ struct SQLStatementGeneratorGeneratedColumnTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny"), @@ -101,8 +101,8 @@ struct SQLStatementGeneratorGeneratedColumnTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) let statement = try #require(statements.first) @@ -115,7 +115,7 @@ struct SQLStatementGeneratorGeneratedColumnTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange( @@ -132,8 +132,8 @@ struct SQLStatementGeneratorGeneratedColumnTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.isEmpty) @@ -143,14 +143,14 @@ struct SQLStatementGeneratorGeneratedColumnTests { func tableWithoutGeneratedColumnsIsUnaffected() throws { let generator = try makeGenerator(generatedColumns: []) let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, - insertedRowData: [0: ["1", "John", "John Doe"]], - deletedRowIndices: [], - insertedRowIndices: [0] + insertedRowData: [.existing(0): ["1", "John", "John Doe"]], + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) let statement = try #require(statements.first) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift index fa493046a1..577bfe1781 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift @@ -28,19 +28,19 @@ struct SQLStatementGeneratorMSSQLTests { ) } - private func makeInsertChange(rowIndex: Int = 0) -> RowChange { - RowChange(rowIndex: rowIndex, type: .insert, cellChanges: [], originalRow: nil) + private func makeInsertChange(rowID: RowID = .existing(0)) -> RowChange { + RowChange(rowID: rowID, type: .insert, cellChanges: [], originalRow: nil) } private func makeUpdateChange( - rowIndex: Int = 0, + rowID: RowID = .existing(0), columnName: String = "name", oldValue: String? = "old", newValue: String? = "new", originalRow: [String?]? = ["1", "old", "a@b.com"] ) -> RowChange { RowChange( - rowIndex: rowIndex, + rowID: rowID, type: .update, cellChanges: [ CellChange( @@ -55,11 +55,11 @@ struct SQLStatementGeneratorMSSQLTests { } private func makeDeleteChange( - rowIndex: Int = 0, + rowID: RowID = .existing(0), originalRow: [String?]? = ["1", "John", "john@example.com"] ) -> RowChange { RowChange( - rowIndex: rowIndex, type: .delete, cellChanges: [], + rowID: rowID, type: .delete, cellChanges: [], originalRow: originalRow.map { row in row.map(PluginCellValue.fromOptional) } ) } @@ -69,12 +69,12 @@ struct SQLStatementGeneratorMSSQLTests { @Test("INSERT statement uses question mark placeholders") func insertUsesQuestionMarkPlaceholders() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -88,8 +88,8 @@ struct SQLStatementGeneratorMSSQLTests { let statements = generator.generateStatements( from: [makeUpdateChange()], insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -102,12 +102,12 @@ struct SQLStatementGeneratorMSSQLTests { @Test("INSERT uses bracket-quoted table and column names") func insertBracketQuoting() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -121,12 +121,12 @@ struct SQLStatementGeneratorMSSQLTests { @Test("INSERT with multiple columns produces correct number of placeholders") func insertMultipleColumnsPlaceholders() throws { let generator = try makeGenerator(columns: ["id", "name", "email"]) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -144,8 +144,8 @@ struct SQLStatementGeneratorMSSQLTests { let statements = generator.generateStatements( from: [makeUpdateChange()], insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -159,8 +159,8 @@ struct SQLStatementGeneratorMSSQLTests { let statements = generator.generateStatements( from: [makeUpdateChange()], insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -176,8 +176,8 @@ struct SQLStatementGeneratorMSSQLTests { let statements = generator.generateStatements( from: [makeDeleteChange()], insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift index d397784a0b..241324199b 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift @@ -36,7 +36,7 @@ struct SQLStatementGeneratorNoPKTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") @@ -48,8 +48,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -68,7 +68,7 @@ struct SQLStatementGeneratorNoPKTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: nil, newValue: "Johnny") @@ -80,8 +80,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -95,7 +95,7 @@ struct SQLStatementGeneratorNoPKTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") @@ -107,8 +107,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.isEmpty) @@ -119,7 +119,7 @@ struct SQLStatementGeneratorNoPKTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny"), @@ -132,8 +132,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -149,15 +149,15 @@ struct SQLStatementGeneratorNoPKTests { func testDeleteNoPKMultipleRows() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), - RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), + RowChange(rowID: .existing(1), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] + deletedRowIDs: [.existing(0), .existing(1)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -175,7 +175,7 @@ struct SQLStatementGeneratorNoPKTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .delete, cellChanges: [], originalRow: [nil, nil, nil] @@ -185,8 +185,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -202,7 +202,7 @@ struct SQLStatementGeneratorNoPKTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .delete, cellChanges: [], originalRow: nil @@ -212,8 +212,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.isEmpty) @@ -226,7 +226,7 @@ struct SQLStatementGeneratorNoPKTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") @@ -234,7 +234,7 @@ struct SQLStatementGeneratorNoPKTests { originalRow: ["1", "John", "john@example.com"] ), RowChange( - rowIndex: 1, + rowID: .existing(1), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"] @@ -244,8 +244,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [1], - insertedRowIndices: [] + deletedRowIDs: [.existing(1)], + insertedRowIDs: [] ) #expect(statements.count == 2) @@ -256,13 +256,13 @@ struct SQLStatementGeneratorNoPKTests { @Test("INSERT + DELETE without PK — INSERT unaffected") func testInsertDeleteNoPK() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["3", "Bob", "bob@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["3", "Bob", "bob@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil), + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil), RowChange( - rowIndex: 1, + rowID: .existing(1), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"] @@ -272,8 +272,8 @@ struct SQLStatementGeneratorNoPKTests { let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [1], - insertedRowIndices: [0] + deletedRowIDs: [.existing(1)], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 2) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOrderingTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOrderingTests.swift index 1f2d425a6b..c8ca8510cb 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOrderingTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOrderingTests.swift @@ -28,7 +28,7 @@ struct SQLStatementGeneratorOrderingTests { private func update(row: Int, sequence: Int) -> RowChange { RowChange( - rowIndex: row, + rowID: .existing(row), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "email", oldValue: "old@b.com", newValue: "new@b.com"), @@ -39,11 +39,11 @@ struct SQLStatementGeneratorOrderingTests { } private func delete(row: Int, sequence: Int) -> RowChange { - RowChange(rowIndex: row, type: .delete, originalRow: [.text("\(row)"), "a@b.com"], sequence: sequence) + RowChange(rowID: .existing(row), type: .delete, originalRow: [.text("\(row)"), "a@b.com"], sequence: sequence) } private func insert(row: Int, sequence: Int) -> RowChange { - RowChange(rowIndex: row, type: .insert, sequence: sequence) + RowChange(rowID: .existing(row), type: .insert, sequence: sequence) } private func verbs(_ statements: [AttributedStatement]) -> [String] { @@ -54,9 +54,9 @@ struct SQLStatementGeneratorOrderingTests { func deleteBeforeInsert() throws { let statements = try makeGenerator().generateAttributedStatements( from: [delete(row: 0, sequence: 0), insert(row: 1, sequence: 1)], - insertedRowData: [1: ["9", "a@b.com"]], - deletedRowIndices: [0], - insertedRowIndices: [1] + insertedRowData: [.existing(1): ["9", "a@b.com"]], + deletedRowIDs: [.existing(0)], + insertedRowIDs: [.existing(1)] ) #expect(verbs(statements) == ["DELETE", "INSERT"]) @@ -66,9 +66,9 @@ struct SQLStatementGeneratorOrderingTests { func mixedOrderIsPreserved() throws { let statements = try makeGenerator().generateAttributedStatements( from: [update(row: 0, sequence: 0), delete(row: 1, sequence: 1), insert(row: 2, sequence: 2)], - insertedRowData: [2: ["9", "c@b.com"]], - deletedRowIndices: [1], - insertedRowIndices: [2] + insertedRowData: [.existing(2): ["9", "c@b.com"]], + deletedRowIDs: [.existing(1)], + insertedRowIDs: [.existing(2)] ) #expect(verbs(statements) == ["UPDATE", "DELETE", "INSERT"]) @@ -78,9 +78,9 @@ struct SQLStatementGeneratorOrderingTests { func arrayOrderIsNotTrusted() throws { let statements = try makeGenerator().generateAttributedStatements( from: [insert(row: 2, sequence: 5), delete(row: 1, sequence: 1)], - insertedRowData: [2: ["9", "c@b.com"]], - deletedRowIndices: [1], - insertedRowIndices: [2] + insertedRowData: [.existing(2): ["9", "c@b.com"]], + deletedRowIDs: [.existing(1)], + insertedRowIDs: [.existing(2)] ) #expect(verbs(statements) == ["DELETE", "INSERT"]) @@ -91,8 +91,8 @@ struct SQLStatementGeneratorOrderingTests { let statements = try makeGenerator().generateAttributedStatements( from: [delete(row: 0, sequence: 0), delete(row: 1, sequence: 1), delete(row: 2, sequence: 2)], insertedRowData: [:], - deletedRowIndices: [0, 1, 2], - insertedRowIndices: [] + deletedRowIDs: [.existing(0), .existing(1), .existing(2)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -108,8 +108,8 @@ struct SQLStatementGeneratorOrderingTests { delete(row: 1, sequence: 2), ], insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] + deletedRowIDs: [.existing(0), .existing(1)], + insertedRowIDs: [] ) #expect(verbs(statements) == ["DELETE", "UPDATE", "DELETE"]) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift index 945db12ca0..1436bedfdf 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift @@ -27,9 +27,9 @@ struct SQLStatementGeneratorPKRegressionTests { ) } - private func makeDeleteChange(rowIndex: Int, originalRow: [String?]) -> RowChange { + private func makeDeleteChange(rowID: RowID, originalRow: [String?]) -> RowChange { RowChange( - rowIndex: rowIndex, + rowID: rowID, type: .delete, cellChanges: [], originalRow: originalRow.map(PluginCellValue.fromOptional) @@ -37,7 +37,7 @@ struct SQLStatementGeneratorPKRegressionTests { } private func makeUpdateChange( - rowIndex: Int, + rowID: RowID, columnIndex: Int, columnName: String, oldValue: String?, @@ -45,7 +45,7 @@ struct SQLStatementGeneratorPKRegressionTests { originalRow: [String?] ) -> RowChange { RowChange( - rowIndex: rowIndex, + rowID: rowID, type: .update, cellChanges: [CellChange(columnIndex: columnIndex, columnName: columnName, oldValue: PluginCellValue.fromOptional(oldValue), newValue: PluginCellValue.fromOptional(newValue))], originalRow: originalRow.map(PluginCellValue.fromOptional) @@ -57,13 +57,13 @@ struct SQLStatementGeneratorPKRegressionTests { @Test("PostgreSQL delete with PK uses $N placeholder and PK-only WHERE") func testPostgreSQLDeleteWithPK() throws { let generator = try makeGenerator(databaseType: .postgresql) - let changes = [makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"])] + let changes = [makeDeleteChange(rowID: .existing(0), originalRow: ["1", "John", "john@test.com"])] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -81,15 +81,15 @@ struct SQLStatementGeneratorPKRegressionTests { func testPostgreSQLBatchDeleteWithPK() throws { let generator = try makeGenerator(databaseType: .postgresql) let changes = [ - makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"]), - makeDeleteChange(rowIndex: 1, originalRow: ["2", "Jane", "jane@test.com"]) + makeDeleteChange(rowID: .existing(0), originalRow: ["1", "John", "john@test.com"]), + makeDeleteChange(rowID: .existing(1), originalRow: ["2", "Jane", "jane@test.com"]) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] + deletedRowIDs: [.existing(0), .existing(1)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -106,13 +106,13 @@ struct SQLStatementGeneratorPKRegressionTests { @Test("MSSQL delete with PK uses ? placeholder and PK-only WHERE") func testMSSQLDeleteWithPK() throws { let generator = try makeGenerator(databaseType: .mssql) - let changes = [makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"])] + let changes = [makeDeleteChange(rowID: .existing(0), originalRow: ["1", "John", "john@test.com"])] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -133,15 +133,15 @@ struct SQLStatementGeneratorPKRegressionTests { func testPostgreSQLUpdateWithPK() throws { let generator = try makeGenerator(databaseType: .postgresql) let changes = [makeUpdateChange( - rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane", originalRow: ["1", "John", "john@test.com"] )] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -156,15 +156,15 @@ struct SQLStatementGeneratorPKRegressionTests { func testMSSQLUpdateWithPK() throws { let generator = try makeGenerator(databaseType: .mssql) let changes = [makeUpdateChange( - rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane", + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane", originalRow: ["1", "John", "john@test.com"] )] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -180,13 +180,13 @@ struct SQLStatementGeneratorPKRegressionTests { @Test("Redshift delete with PK uses $N placeholder and PK-only WHERE") func testRedshiftDeleteWithPK() throws { let generator = try makeGenerator(databaseType: .redshift) - let changes = [makeDeleteChange(rowIndex: 0, originalRow: ["1", "John", "john@test.com"])] + let changes = [makeDeleteChange(rowID: .existing(0), originalRow: ["1", "John", "john@test.com"])] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift index 559ccab7c5..e48fae3fc4 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift @@ -37,14 +37,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("PostgreSQL defaults to dollar style") func testPostgreSQLDefaultsDollar() throws { let generator = try makeGenerator(databaseType: .postgresql) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -57,14 +57,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("Redshift defaults to dollar style") func testRedshiftDefaultsDollar() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -74,14 +74,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("DuckDB defaults to dollar style") func testDuckDBDefaultsDollar() throws { let generator = try makeGenerator(databaseType: .duckdb) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -91,14 +91,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("MySQL defaults to questionMark style") func testMySQLDefaultsQuestionMark() throws { let generator = try makeGenerator(databaseType: .mysql) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -109,14 +109,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("SQLite defaults to questionMark style") func testSQLiteDefaultsQuestionMark() throws { let generator = try makeGenerator(databaseType: .sqlite) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -127,14 +127,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("MSSQL defaults to questionMark style") func testMSSQLDefaultsQuestionMark() throws { let generator = try makeGenerator(databaseType: .mssql) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -147,14 +147,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("Dollar style generates $1, $2 placeholders for INSERT") func testDollarStyleInsert() throws { let generator = try makeGenerator(parameterStyle: .dollar) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -167,14 +167,14 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("QuestionMark style generates ? placeholders for INSERT") func testQuestionMarkStyleInsert() throws { let generator = try makeGenerator(parameterStyle: .questionMark) - let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], insertedRowIndices: [0] + deletedRowIDs: [], insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -188,7 +188,7 @@ struct SQLStatementGeneratorParameterStyleTests { let generator = try makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Jane") @@ -199,7 +199,7 @@ struct SQLStatementGeneratorParameterStyleTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], insertedRowIndices: [] + deletedRowIDs: [], insertedRowIDs: [] ) #expect(statements.count == 1) @@ -213,7 +213,7 @@ struct SQLStatementGeneratorParameterStyleTests { let generator = try makeGenerator(databaseType: .postgresql, parameterStyle: .dollar) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"] @@ -222,7 +222,7 @@ struct SQLStatementGeneratorParameterStyleTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], insertedRowIndices: [] + deletedRowIDs: [.existing(0)], insertedRowIDs: [] ) #expect(statements.count == 1) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorRowMatchTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorRowMatchTests.swift index 2b697693b0..e0cfba36df 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorRowMatchTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorRowMatchTests.swift @@ -28,7 +28,7 @@ struct SQLStatementGeneratorRowMatchTests { @Test("An update matches on every column the engine can compare, and none it cannot") func updateLeavesExcludedColumnsOut() throws { let change = RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [CellChange(columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b")], originalRow: originalRow @@ -40,9 +40,9 @@ struct SQLStatementGeneratorRowMatchTests { @Test("A delete matches the same way") func deleteLeavesExcludedColumnsOut() throws { - let change = RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: originalRow) + let change = RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: originalRow) let statements = try generator(excluding: ["payload", "tags"]).generateStatements( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] + from: [change], insertedRowData: [:], deletedRowIDs: [.existing(0)], insertedRowIDs: [] ) #expect(statements.map(\.sql) == ["DELETE FROM `t` WHERE (`id` = ? AND `name` = ?)"]) } @@ -51,11 +51,11 @@ struct SQLStatementGeneratorRowMatchTests { func keylessDeletesAreNotBatched() throws { let other: [PluginCellValue] = ["2", "b", "{}", "[]"] let changes = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: originalRow), - RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: other) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: originalRow), + RowChange(rowID: .existing(1), type: .delete, cellChanges: [], originalRow: other) ] let statements = try generator(excluding: ["payload", "tags"]).generateAttributedStatements( - from: changes, insertedRowData: [:], deletedRowIndices: [0, 1], insertedRowIndices: [] + from: changes, insertedRowData: [:], deletedRowIDs: [.existing(0), .existing(1)], insertedRowIDs: [] ) #expect(statements.count == 2) #expect(statements.allSatisfy { $0.rowCount == 1 && !$0.statement.sql.contains(" OR ") }) @@ -63,21 +63,21 @@ struct SQLStatementGeneratorRowMatchTests { @Test("A Databend row left entirely on defaults names one column with DEFAULT") func databendAllDefaultsInsert() throws { - let change = RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let change = RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) let statements = try generator(excluding: []).generateStatements( from: [change], - insertedRowData: [0: [.text("__DEFAULT__"), .text("__DEFAULT__"), .text("__DEFAULT__"), .text("__DEFAULT__")]], - deletedRowIndices: [], - insertedRowIndices: [0] + insertedRowData: [.existing(0): [.text("__DEFAULT__"), .text("__DEFAULT__"), .text("__DEFAULT__"), .text("__DEFAULT__")]], + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.map(\.sql) == ["INSERT INTO `t` (`id`) VALUES (DEFAULT)"]) } @Test("With nothing excluded, every column still identifies the row") func noExclusionsKeepsEveryColumn() throws { - let change = RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: originalRow) + let change = RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: originalRow) let statements = try generator(excluding: []).generateStatements( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] + from: [change], insertedRowData: [:], deletedRowIDs: [.existing(0)], insertedRowIDs: [] ) #expect(statements.first?.sql.contains("`payload` = ?") == true) #expect(statements.first?.sql.contains("`tags` = ?") == true) @@ -94,9 +94,9 @@ struct SQLStatementGeneratorRowMatchTests { databaseType: .mysql, pluginDriver: nil ) - let change = RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["{}", "[]"]) + let change = RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["{}", "[]"]) #expect(throws: DataWriteError.self) { - _ = try factory.statements(for: [change], deletedRowIndices: [0]) + _ = try factory.statements(for: [change], deletedRowIDs: [.existing(0)]) } } diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index af9b232530..3a040746f7 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -35,12 +35,12 @@ struct SQLStatementGeneratorTests { @Test("Simple insert from insertedRowData (MySQL)") func testSimpleInsertMySQL() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil @@ -50,8 +50,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -71,18 +71,18 @@ struct SQLStatementGeneratorTests { @Test("Insert with NULL value") func testInsertWithNullValue() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", nil] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", nil] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -93,18 +93,18 @@ struct SQLStatementGeneratorTests { @Test("Insert skips __DEFAULT__ columns") func testInsertSkipsDefaultColumns() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["__DEFAULT__", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["__DEFAULT__", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -128,18 +128,18 @@ struct SQLStatementGeneratorTests { ) func testInsertAllDefaultNamesNoColumn(databaseType: DatabaseType, expected: String) throws { let generator = try makeGenerator(databaseType: databaseType) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["__DEFAULT__", "__DEFAULT__", "__DEFAULT__"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["__DEFAULT__", "__DEFAULT__", "__DEFAULT__"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -153,7 +153,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .insert, cellChanges: [ CellChange(columnIndex: 0, columnName: "id", oldValue: nil, newValue: "1"), @@ -167,8 +167,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -178,18 +178,18 @@ struct SQLStatementGeneratorTests { @Test("Insert with SQL function is inlined") func testInsertWithSQLFunction() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "NOW()"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "NOW()"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -201,18 +201,18 @@ struct SQLStatementGeneratorTests { @Test("PostgreSQL insert uses $1, $2 placeholders") func testInsertPostgreSQLPlaceholders() throws { let generator = try makeGenerator(databaseType: .postgresql) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -224,18 +224,18 @@ struct SQLStatementGeneratorTests { @Test("Table name is quoted with identifier quote") func testTableNameQuoted() throws { let generator = try makeGenerator(tableName: "my_table") - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -245,18 +245,18 @@ struct SQLStatementGeneratorTests { @Test("Column names are quoted") func testColumnNamesQuoted() throws { let generator = try makeGenerator(columns: ["user_id", "full_name", "email_address"]) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -269,20 +269,20 @@ struct SQLStatementGeneratorTests { @Test("Insert multiple rows generates separate statements") func testInsertMultipleRows() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"], - 1: ["2", "Jane", "jane@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"], + .existing(1): ["2", "Jane", "jane@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil), - RowChange(rowIndex: 1, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil), + RowChange(rowID: .existing(1), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0, 1] + deletedRowIDs: [], + insertedRowIDs: [.existing(0), .existing(1)] ) #expect(statements.count == 2) @@ -297,7 +297,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") @@ -309,8 +309,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -330,7 +330,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny"), @@ -343,8 +343,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -359,7 +359,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 2, columnName: "email", oldValue: "john@example.com", newValue: nil) @@ -371,8 +371,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -384,7 +384,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "__DEFAULT__") @@ -396,8 +396,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -411,7 +411,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 2, columnName: "email", oldValue: "old@example.com", newValue: "CURRENT_TIMESTAMP()") @@ -423,8 +423,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -438,7 +438,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") @@ -450,8 +450,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -465,7 +465,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") @@ -477,8 +477,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -492,7 +492,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator() let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"] @@ -502,8 +502,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -517,15 +517,15 @@ struct SQLStatementGeneratorTests { func testBatchDeleteMultipleRows() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), - RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), + RowChange(rowID: .existing(1), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] + deletedRowIDs: [.existing(0), .existing(1)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -541,7 +541,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator(primaryKeyColumns: []) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"] @@ -551,8 +551,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -569,7 +569,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator(primaryKeyColumns: []) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", nil] @@ -579,8 +579,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -593,15 +593,15 @@ struct SQLStatementGeneratorTests { func testDeletePostgreSQLPlaceholders() throws { let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), - RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), + RowChange(rowID: .existing(1), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] + deletedRowIDs: [.existing(0), .existing(1)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -614,14 +614,14 @@ struct SQLStatementGeneratorTests { func testDeleteRequiresOriginalRow() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.isEmpty) @@ -634,8 +634,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: [], insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.isEmpty) @@ -645,26 +645,26 @@ struct SQLStatementGeneratorTests { func testMixedOperations() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil), + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil), RowChange( - rowIndex: 1, + rowID: .existing(1), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") ], originalRow: ["1", "John", "john@example.com"] ), - RowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) + RowChange(rowID: .existing(2), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["3", "Bob", "bob@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["3", "Bob", "bob@example.com"] ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [2], - insertedRowIndices: [0] + deletedRowIDs: [.existing(2)], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 3) @@ -675,18 +675,18 @@ struct SQLStatementGeneratorTests { @Test("MySQL uses ? for all placeholders") func testMySQLPlaceholders() throws { let generator = try makeGenerator(databaseType: .mysql) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -698,18 +698,18 @@ struct SQLStatementGeneratorTests { @Test("PostgreSQL uses $1, $2, $3 sequentially") func testPostgreSQLSequentialPlaceholders() throws { let generator = try makeGenerator(databaseType: .postgresql) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -723,18 +723,18 @@ struct SQLStatementGeneratorTests { @Test("SQLite uses ? placeholders") func testSQLitePlaceholders() throws { let generator = try makeGenerator(databaseType: .sqlite) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -745,18 +745,18 @@ struct SQLStatementGeneratorTests { @Test("MariaDB uses ? placeholders") func testMariaDBPlaceholders() throws { let generator = try makeGenerator(databaseType: .mariadb) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -766,42 +766,42 @@ struct SQLStatementGeneratorTests { // MARK: - Safety Tests - @Test("Insert only processes rows in insertedRowIndices set") + @Test("Insert only processes rows in insertedRowIDs set") func testInsertOnlyProcessesInsertedRows() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"], - 1: ["2", "Jane", "jane@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"], + .existing(1): ["2", "Jane", "jane@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil), - RowChange(rowIndex: 1, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil), + RowChange(rowID: .existing(1), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) #expect(statements[0].parameters[1] as? String == "John") } - @Test("Delete only processes rows in deletedRowIndices set") + @Test("Delete only processes rows in deletedRowIDs set") func testDeleteOnlyProcessesDeletedRows() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), - RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), + RowChange(rowID: .existing(1), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -809,38 +809,38 @@ struct SQLStatementGeneratorTests { #expect(statements[0].parameters[0] as? String == "1") } - @Test("Row not in insertedRowIndices is skipped") + @Test("Row not in insertedRowIDs is skipped") func testRowNotInInsertedRowIndicesSkipped() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.isEmpty) } - @Test("Row not in deletedRowIndices is skipped") + @Test("Row not in deletedRowIDs is skipped") func testRowNotInDeletedRowIndicesSkipped() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.isEmpty) @@ -852,26 +852,26 @@ struct SQLStatementGeneratorTests { func testFullWorkflowIntegration() throws { let generator = try makeGenerator() let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil), + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil), RowChange( - rowIndex: 1, + rowID: .existing(1), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") ], originalRow: ["1", "John", "john@example.com"] ), - RowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) + RowChange(rowID: .existing(2), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["3", "Bob", "bob@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["3", "Bob", "bob@example.com"] ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [2], - insertedRowIndices: [0] + deletedRowIDs: [.existing(2)], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 3) @@ -885,7 +885,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator(databaseType: .postgresql) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny"), @@ -898,8 +898,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -923,18 +923,18 @@ struct SQLStatementGeneratorTests { @Test("Redshift insert uses $1, $2 placeholders") func testInsertRedshiftPlaceholders() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -948,18 +948,18 @@ struct SQLStatementGeneratorTests { @Test("Redshift insert uses double-quote identifier quoting") func testInsertRedshiftQuoting() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -976,7 +976,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator(databaseType: .redshift) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny") @@ -988,8 +988,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -1003,15 +1003,15 @@ struct SQLStatementGeneratorTests { func testDeleteRedshiftPlaceholders() throws { let generator = try makeGenerator(databaseType: .redshift) let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), - RowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) + RowChange(rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "John", "john@example.com"]), + RowChange(rowID: .existing(1), type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] + deletedRowIDs: [.existing(0), .existing(1)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -1024,18 +1024,18 @@ struct SQLStatementGeneratorTests { @Test("Redshift uses $1, $2, $3 sequentially for insert") func testRedshiftSequentialPlaceholders() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "John", "john@example.com"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -1051,7 +1051,7 @@ struct SQLStatementGeneratorTests { let generator = try makeGenerator(databaseType: .redshift) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny"), @@ -1064,8 +1064,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -1091,7 +1091,7 @@ struct SQLStatementGeneratorTests { ) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "database", oldValue: "old_db", newValue: "new_db") @@ -1103,8 +1103,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -1121,18 +1121,18 @@ struct SQLStatementGeneratorTests { columns: ["id", "database", "order"], primaryKeyColumns: ["id"] ) - let insertedRowData: [Int: [PluginCellValue]] = [ - 0: ["1", "mydb", "5"] + let insertedRowData: [RowID: [PluginCellValue]] = [ + .existing(0): ["1", "mydb", "5"] ] let changes: [RowChange] = [ - RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) ] let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: [], - insertedRowIndices: [0] + deletedRowIDs: [], + insertedRowIDs: [.existing(0)] ) #expect(statements.count == 1) @@ -1152,7 +1152,7 @@ struct SQLStatementGeneratorTests { ) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .delete, cellChanges: [], originalRow: ["1", "mydb", "foo"] @@ -1162,8 +1162,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] + deletedRowIDs: [.existing(0)], + insertedRowIDs: [] ) #expect(statements.count == 1) @@ -1182,7 +1182,7 @@ struct SQLStatementGeneratorTests { ) let changes: [RowChange] = [ RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: 1, columnName: "database", oldValue: "old_db", newValue: "new_db") @@ -1194,8 +1194,8 @@ struct SQLStatementGeneratorTests { let statements = generator.generateStatements( from: changes, insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] + deletedRowIDs: [], + insertedRowIDs: [] ) #expect(statements.count == 1) diff --git a/TableProTests/Core/Compare/CompareSQLLiteralTests.swift b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift index 0cb4a9d813..5cd1dbe44d 100644 --- a/TableProTests/Core/Compare/CompareSQLLiteralTests.swift +++ b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift @@ -15,12 +15,27 @@ final class CompareSQLLiteralTests: XCTestCase { private let png = Data([0x89, 0x50, 0x4E, 0x47]) func testBitStringEnginesKeepTheDefaultSpelling() throws { - for type in [DatabaseType.mysql, .mariadb, .tidb, .databend, .sqlite, .clickhouse, .duckdb, .libsql, .turso, .cloudflareD1] { + for type in [DatabaseType.mysql, .mariadb, .tidb, .databend, .oceanbase, .sqlite, .clickhouse, .duckdb, .libsql, .turso, .cloudflareD1] { let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) XCTAssertEqual(literal, "X'89504E47'", "\(type.rawValue) uses a bit-string literal") } } + /// A compare script runs its own statements, so a plain literal on a SQL Server database with + /// a non-Unicode collation writes `?` into the target rather than the text it compared. + func testTextLiteralsCarryTheSQLServerPrefix() { + XCTAssertEqual(CompareSQLLiteral.prefixed("'日本語'", databaseType: .mssql), "N'日本語'") + XCTAssertEqual(CompareSQLLiteral.prefixed("'abc'", databaseType: .mysql), "'abc'") + } + + /// `sqlLiteral(for:)` answers `NULL` for a null and passes a number through unquoted. `N` in + /// front of either is a syntax error. + func testOnlyQuotedLiteralsTakeThePrefix() { + XCTAssertEqual(CompareSQLLiteral.prefixed("NULL", databaseType: .mssql), "NULL") + XCTAssertEqual(CompareSQLLiteral.prefixed("42", databaseType: .mssql), "42") + XCTAssertEqual(CompareSQLLiteral.prefixed("0x89504E47", databaseType: .mssql), "0x89504E47") + } + func testPostgresFamilyUsesAByteaCast() throws { for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite] { let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) diff --git a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift index 7e56a6c1a4..450a69ccbe 100644 --- a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift +++ b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift @@ -351,6 +351,8 @@ final class CompareSyncEngineFamilyTests: XCTestCase { XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .tidb)) XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .tidb, to: .mariadb)) XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .databend)) + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .oceanbase, to: .oceanbase)) + XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .oceanbase)) } func testUnrelatedEnginesAreRefused() { diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift index 12ff8930b0..336a6117cb 100644 --- a/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift @@ -130,7 +130,7 @@ struct RowEditingCoordinatorCopyTests { } /// Fetch All extends the same result rather than replacing it, so the filter stays and the - /// order is re-resolved over the rows that arrived. The memo keys on the registry's data + /// order is re-resolved over the rows that arrived. The memo keys on the registry's row-set /// revision, so this holds with no grid mounted to recompute it. @Test("loading more rows into the same result keeps the value filter and re-resolves the order") func loadingMoreRowsKeepsTheValueFilter() { diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift index 8e6f682e36..8fb7a7eb0b 100644 --- a/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift @@ -79,7 +79,7 @@ struct RowEditingCoordinatorJsonModeTests { coordinator.deleteSelectedRows(indices: [0]) - #expect(coordinator.selectionState.indices == [0]) + #expect(coordinator.selectionState.indices == [1]) } @Test("duplicating in JSON mode leaves the selection alone") diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorValueFilterTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorValueFilterTests.swift new file mode 100644 index 0000000000..f071e73068 --- /dev/null +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorValueFilterTests.swift @@ -0,0 +1,149 @@ +// +// RowEditingCoordinatorValueFilterTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("RowEditingCoordinator under a value filter") +@MainActor +struct RowEditingCoordinatorValueFilterTests { + private func makeCoordinator() -> MainContentCoordinator { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table, tableName: "users") + tab.execution.lastExecutedAt = Date() + tab.display.resultsViewMode = .data + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [ + [.text("1"), .text("Alice")], + [.text("2"), .text("Bob")], + [.text("3"), .text("Cleo")], + [.text("4"), .text("Bob")] + ], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)], + hasAuthoritativeSchema: true + ), + for: tab.id + ) + coordinator.changeManager.configureForTable( + tableName: "users", + columns: ["id", "name"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + + var filter = GridValueFilterState() + filter.set( + ColumnValueFilter(selectedValues: ["Bob"], includesNull: false), + columnName: "name", + forColumn: 1 + ) + coordinator.setValueFilter(filter, forTab: tab.id) + return coordinator + } + + private func tableRows(of coordinator: MainContentCoordinator) -> TableRows { + guard let tabId = coordinator.tabManager.selectedTab?.id else { return TableRows() } + return coordinator.tabSessionRegistry.tableRows(for: tabId) + } + + @Test("The filter shows the two Bobs, so display positions are not storage indices") + func filterNarrowsTheDisplay() { + let coordinator = makeCoordinator() + + #expect(coordinator.activeGridDisplayIDs == [.existing(1), .existing(3)]) + } + + @Test("Deleting the second shown row marks id 4, and the DELETE names id 4") + func deleteMarksTheShownRow() throws { + let coordinator = makeCoordinator() + + coordinator.deleteSelectedRows(indices: [1]) + + #expect(coordinator.changeManager.isRowDeleted(.existing(3))) + #expect(!coordinator.changeManager.isRowDeleted(.existing(1))) + let statements = try coordinator.changeManager.generateSQL() + #expect(statements.count == 1) + #expect(statements.first?.parameters.first.flatMap { $0 as? String } == "4") + } + + @Test("A new row is selected at the position the filter shows it") + func addRowSelectsItsDisplayPosition() { + let coordinator = makeCoordinator() + + coordinator.addNewRow() + + let displayIDs = coordinator.activeGridDisplayIDs ?? [] + #expect(displayIDs.count == 3) + #expect(displayIDs.last?.isInserted == true) + #expect(coordinator.selectionState.indices == [2]) + } + + @Test("A duplicated row is selected at the position the filter shows it") + func duplicateSelectsItsDisplayPosition() { + let coordinator = makeCoordinator() + + coordinator.duplicateSelectedRow(index: 1) + + let rows = tableRows(of: coordinator) + #expect(rows.count == 5) + #expect(rows.rows.last?.values[1] == "Bob") + #expect(coordinator.selectionState.indices == [2]) + } + + @Test("Discard puts back the row that was edited, not the one at the same storage offset") + func discardRestoresTheEditedRow() { + let coordinator = makeCoordinator() + guard let tabId = coordinator.tabManager.selectedTab?.id else { + Issue.record("No selected tab") + return + } + coordinator.changeManager.recordCellChange( + rowID: .existing(3), + columnIndex: 1, + columnName: "name", + oldValue: "Bob", + newValue: "Robert", + originalRow: ["4", "Bob"] + ) + coordinator.mutateActiveTableRows(for: tabId) { rows in + rows.edit(row: 3, column: 1, value: "Robert") + } + + coordinator.rowEditingCoordinator.restoreRowBufferToOriginals() + + let rows = tableRows(of: coordinator) + #expect(rows.rows[3].values[1] == "Bob") + #expect(rows.rows[1].values[1] == "Bob") + #expect(rows.rows[2].values[1] == "Cleo") + } + + @Test("Discard removes rows added under the filter") + func discardRemovesInsertedRows() { + let coordinator = makeCoordinator() + coordinator.addNewRow() + #expect(tableRows(of: coordinator).count == 5) + + coordinator.rowEditingCoordinator.restoreRowBufferToOriginals() + + let rows = tableRows(of: coordinator) + #expect(rows.count == 4) + #expect(!rows.rows.contains { $0.id.isInserted }) + } +} diff --git a/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift b/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift index 97624f41b0..66fae2eb00 100644 --- a/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift +++ b/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift @@ -281,6 +281,32 @@ final class CrossEngineStructureTranslatorTests: XCTestCase { // MARK: - Value kinds + func testAJSONColumnCopiedToAPostgresServerWithoutJSONArrivesAsTextWithANote() { + let source = snapshot(columns: [column("payload", "JSON")]) + let result = CrossEngineStructureTranslator.translate( + source, from: .mysql, to: .postgresql, targetServerVersion: "9.1.24" + ) + XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["TEXT"]) + XCTAssertTrue(result.notes.contains { $0.subject == "payload" }) + } + + func testAJSONColumnCopiedToAPostgresServerWithoutJSONBArrivesAsJSON() { + let source = snapshot(columns: [column("payload", "JSON")]) + let result = CrossEngineStructureTranslator.translate( + source, from: .mysql, to: .postgresql, targetServerVersion: "9.3.25" + ) + XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["JSON"]) + XCTAssertTrue(result.notes.isEmpty) + } + + func testAJSONColumnCopiedIntoRedshiftKeepsJSONBWhateverVersionItReports() { + let source = snapshot(columns: [column("payload", "JSON")]) + let result = CrossEngineStructureTranslator.translate( + source, from: .mysql, to: .redshift, targetServerVersion: "8.0.2" + ) + XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["JSONB"]) + } + func testTheTargetKindsDescribeEveryColumn() { let source = snapshot(columns: [column("flag", "TINYINT(1)"), column("made", "DATETIME")]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) diff --git a/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift b/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift index 58d1a5f2a9..68128999d2 100644 --- a/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift +++ b/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift @@ -37,6 +37,38 @@ final class SQLTypeRendererTests: XCTestCase { XCTAssertEqual(spelling("DOUBLE", from: .mysql, to: .postgres), "DOUBLE PRECISION") } + func testJSONForAPostgresTargetFollowsTheServerVersion() { + let json = SQLTypeParser.parse("JSON", family: .mysql) + XCTAssertEqual(SQLTypeRenderer.render(json, family: .postgres).spelling, "JSONB") + XCTAssertEqual(SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .jsonb).spelling, "JSONB") + XCTAssertEqual(SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .json).spelling, "JSON") + + let preJSON = SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .text) + XCTAssertEqual(preJSON.spelling, "TEXT") + XCTAssertEqual(preJSON.fidelity, .approximated) + XCTAssertNotNil(preJSON.reason) + } + + func testAJSONArrayForAPostgresTargetFollowsTheServerVersion() { + let array = CanonicalColumnType(kind: .array(element: .json), isUnsigned: false, sourceSpelling: "jsonb[]") + XCTAssertEqual(SQLTypeRenderer.render(array, family: .postgres, jsonColumnType: .json).spelling, "JSON[]") + XCTAssertEqual(SQLTypeRenderer.render(array, family: .postgres).spelling, "JSONB[]") + } + + /// Redshift reports 8.0.2 and CockroachDB reports 13.0.0. Reading either as a PostgreSQL + /// version turned a `JSONB` column into `TEXT` on a target that holds `JSONB` perfectly well. + func testOnlyPostgreSQLItselfReadsTheReportedVersion() { + XCTAssertEqual( + PostgreSQLServerVersion.jsonColumnType(for: .postgresql, serverVersion: "9.1.24"), .text + ) + XCTAssertEqual( + PostgreSQLServerVersion.jsonColumnType(for: .redshift, serverVersion: "8.0.2"), .jsonb + ) + XCTAssertEqual( + PostgreSQLServerVersion.jsonColumnType(for: .cockroachdb, serverVersion: "13.0.0"), .jsonb + ) + } + func testPostgresToMySQL() { XCTAssertEqual(spelling("boolean", from: .postgres, to: .mysql), "TINYINT(1)") XCTAssertEqual(spelling("int4", from: .postgres, to: .mysql), "INT") diff --git a/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift index fdf38700ae..ae2b3a0286 100644 --- a/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift +++ b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift @@ -191,6 +191,27 @@ struct RowDisplayCacheTests { #expect(cache.box(forID: .existing(1)) == nil) } + @Test("A row's highlight lives and dies with its formatted text") + func highlightSharesTheTextLifetime() { + let cache = RowDisplayCache() + let highlight = RowHighlight(rowRule: HighlightRule(columnName: "c", value: "x"), cellRules: [:]) + cache.setBox(makeBox(["x"]), forID: .existing(1)) + cache.setHighlight(highlight, forID: .existing(1)) + cache.setHighlight(highlight, forID: .existing(2)) + + cache.clearValues(forID: .existing(1)) + #expect(cache.highlight(forID: .existing(1)) == nil) + #expect(cache.highlight(forID: .existing(2)) == highlight) + + cache.clearHighlights() + #expect(cache.highlight(forID: .existing(2)) == nil) + #expect(cache.box(forID: .existing(1)) != nil) + + cache.setHighlight(highlight, forID: .existing(3)) + cache.removeAll() + #expect(cache.highlight(forID: .existing(3)) == nil) + } + @Test("Inserted row IDs of both kinds round-trip") func mixedRowIDKinds() { let cache = RowDisplayCache() diff --git a/TableProTests/Core/DataWrite/PluginKeyedChangesTests.swift b/TableProTests/Core/DataWrite/PluginKeyedChangesTests.swift new file mode 100644 index 0000000000..c6c82f4a3d --- /dev/null +++ b/TableProTests/Core/DataWrite/PluginKeyedChangesTests.swift @@ -0,0 +1,73 @@ +// +// PluginKeyedChangesTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("Plugin keyed changes") +struct PluginKeyedChangesTests { + @Test("Every row gets its own key, and the key agrees across the changes and the sets") + func keysAgreeAcrossCollections() { + let insertedA = RowID.inserted(UUID()) + let insertedB = RowID.inserted(UUID()) + let changes = [ + RowChange(rowID: .existing(40), type: .delete, originalRow: ["40"]), + RowChange(rowID: insertedA, type: .insert), + RowChange(rowID: .existing(7), type: .update, originalRow: ["7"]), + RowChange(rowID: insertedB, type: .insert) + ] + + let keyed = PluginKeyedChanges( + changes: changes, + insertedRowData: [insertedA: ["a"], insertedB: ["b"]], + deletedRowIDs: [.existing(40)], + insertedRowIDs: [insertedA, insertedB] + ) + + let keys = keyed.changes.map(\.rowIndex) + #expect(Set(keys).count == 4) + #expect(keyed.deletedRowIndices == [keys[0]]) + #expect(keyed.insertedRowIndices == [keys[1], keys[3]]) + #expect(keyed.insertedRowData[keys[1]] == ["a"]) + #expect(keyed.insertedRowData[keys[3]] == ["b"]) + #expect(keyed.insertedRowData[keys[2]] == nil) + } + + @Test("A row marked in a set but carried by no change is left out") + func rowsWithoutAChangeAreDropped() { + let orphan = RowID.inserted(UUID()) + let keyed = PluginKeyedChanges( + changes: [RowChange(rowID: .existing(1), type: .delete, originalRow: ["1"])], + insertedRowData: [orphan: ["x"]], + deletedRowIDs: [.existing(1), .existing(2)], + insertedRowIDs: [orphan] + ) + + #expect(keyed.changes.count == 1) + #expect(keyed.deletedRowIndices.count == 1) + #expect(keyed.insertedRowIndices.isEmpty) + #expect(keyed.insertedRowData.isEmpty) + } + + @Test("The change keeps its type, cells and original row across the boundary") + func changeContentCrossesUnchanged() { + let change = RowChange( + rowID: .existing(3), + type: .update, + cellChanges: [CellChange(columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b")], + originalRow: ["3", "a"] + ) + + let keyed = PluginKeyedChanges(changes: [change], insertedRowData: [:], deletedRowIDs: [], insertedRowIDs: []) + + let crossed = keyed.changes.first + #expect(crossed?.type == .update) + #expect(crossed?.cellChanges.first?.columnName == "name") + #expect(crossed?.cellChanges.first?.newValue == "b") + #expect(crossed?.originalRow == ["3", "a"]) + } +} diff --git a/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift b/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift index 34a2ae1567..7d1e075d67 100644 --- a/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift +++ b/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift @@ -15,9 +15,9 @@ struct RowWriteOperationBuilderTests { private func operations( changes: [RowChange], - insertedRowData: [Int: [PluginCellValue]] = [:], - deletedRowIndices: Set = [], - insertedRowIndices: Set = [], + insertedRowData: [RowID: [PluginCellValue]] = [:], + deletedRowIDs: Set = [], + insertedRowIDs: Set = [], primaryKeyColumns: [String] = ["id"], generatedColumns: Set = [], containsTableOperation: Bool = false @@ -25,8 +25,8 @@ struct RowWriteOperationBuilderTests { RowWriteOperationBuilder.operations( from: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices, + deletedRowIDs: deletedRowIDs, + insertedRowIDs: insertedRowIDs, target: target, columns: columns, primaryKeyColumns: primaryKeyColumns, @@ -37,7 +37,7 @@ struct RowWriteOperationBuilderTests { private func cellEdit(column: String, index: Int, from old: PluginCellValue, to new: PluginCellValue) -> RowChange { RowChange( - rowIndex: 0, + rowID: .existing(0), type: .update, cellChanges: [ CellChange(columnIndex: index, columnName: column, oldValue: old, newValue: new), @@ -110,8 +110,8 @@ struct RowWriteOperationBuilderTests { @Test("A delete keeps the whole row") func deleteCapturesTheRow() { - let change = RowChange(rowIndex: 0, type: .delete, originalRow: ["7", "Ada", "2026-01-01"]) - let result = operations(changes: [change], deletedRowIndices: [0]) + let change = RowChange(rowID: .existing(0), type: .delete, originalRow: ["7", "Ada", "2026-01-01"]) + let result = operations(changes: [change], deletedRowIDs: [.existing(0)]) #expect(result.first?.kind == .delete) #expect(result.first?.preImage == ["7", "Ada", "2026-01-01"]) @@ -121,18 +121,18 @@ struct RowWriteOperationBuilderTests { @Test("An insert whose key the server chooses cannot be taken back") func serverAssignedKeyIsRefused() { - let change = RowChange(rowIndex: 0, type: .insert) + let change = RowChange(rowID: .existing(0), type: .insert) let withMarker = operations( changes: [change], - insertedRowData: [0: [.text(PluginCellValue.defaultMarkerText), "Ada", "2026-01-01"]], - insertedRowIndices: [0] + insertedRowData: [.existing(0): [.text(PluginCellValue.defaultMarkerText), "Ada", "2026-01-01"]], + insertedRowIDs: [.existing(0)] ) #expect(withMarker.first?.refusal == .serverAssignedKey) let withNull = operations( changes: [change], - insertedRowData: [0: [.null, "Ada", "2026-01-01"]], - insertedRowIndices: [0] + insertedRowData: [.existing(0): [.null, "Ada", "2026-01-01"]], + insertedRowIDs: [.existing(0)] ) #expect(withNull.first?.refusal == .serverAssignedKey) } @@ -140,9 +140,9 @@ struct RowWriteOperationBuilderTests { @Test("An insert carrying its own key is reversible") func userSuppliedKeyIsReversible() { let result = operations( - changes: [RowChange(rowIndex: 0, type: .insert)], - insertedRowData: [0: ["7", "Ada", "2026-01-01"]], - insertedRowIndices: [0] + changes: [RowChange(rowID: .existing(0), type: .insert)], + insertedRowData: [.existing(0): ["7", "Ada", "2026-01-01"]], + insertedRowIDs: [.existing(0)] ) #expect(result.first?.refusal == nil) #expect(result.first?.postImage == ["7", "Ada", "2026-01-01"]) diff --git a/TableProTests/Core/Database/ConnectionVerificationTests.swift b/TableProTests/Core/Database/ConnectionVerificationTests.swift index 60c518a1be..8458c8c027 100644 --- a/TableProTests/Core/Database/ConnectionVerificationTests.swift +++ b/TableProTests/Core/Database/ConnectionVerificationTests.swift @@ -47,6 +47,19 @@ struct ConnectionVerificationTests { await cleanUp(connection.id) } + @Test("a driver that reported a lost connection is checked even though it answered recently") + func lostDriverIgnoresTheFreshStamp() async { + let driver = MockDatabaseDriver() + driver.hasLostConnection = true + let connection = makeSession(driver: driver) + DatabaseManager.shared.markSessionVerified(connection.id) + + await DatabaseManager.shared.verifyBeforeUse(connection.id) + + #expect(driver.pingCallCount == 1) + await cleanUp(connection.id) + } + @Test("a connection that has been silent too long is checked once") func staleConnectionIsPingedOnce() async { let driver = MockDatabaseDriver() diff --git a/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift index e5b73df353..6ae2760d0e 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift @@ -18,7 +18,10 @@ struct FilterSQLGeneratorMSSQLTests { likeEscapeStyle: .explicit, paginationStyle: .offsetFetch ) - private let generator = FilterSQLGenerator(dialect: Self.mssqlDialect) + private let generator = FilterSQLGenerator( + dialect: Self.mssqlDialect, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(.mssql) + ) // MARK: - Helpers @@ -37,21 +40,21 @@ struct FilterSQLGeneratorMSSQLTests { func equalOperator() { let filter = makeFilter(op: .equal) let result = generator.generateCondition(from: filter) - #expect(result == "[name] = 'test'") + #expect(result == "[name] = N'test'") } @Test("Not equal operator uses bracket-quoted column") func notEqualOperator() { let filter = makeFilter(op: .notEqual) let result = generator.generateCondition(from: filter) - #expect(result == "[name] != 'test'") + #expect(result == "[name] != N'test'") } @Test("Contains operator generates LIKE with ESCAPE clause") func containsOperator() { let filter = makeFilter(op: .contains) let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] LIKE '%test%'") == true) + #expect(result?.contains("[name] LIKE N'%test%'") == true) #expect(result?.contains("ESCAPE") == true) } @@ -59,7 +62,7 @@ struct FilterSQLGeneratorMSSQLTests { func notContainsOperator() { let filter = makeFilter(op: .notContains) let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] NOT LIKE '%test%'") == true) + #expect(result?.contains("[name] NOT LIKE N'%test%'") == true) #expect(result?.contains("ESCAPE") == true) } @@ -67,7 +70,7 @@ struct FilterSQLGeneratorMSSQLTests { func startsWithOperator() { let filter = makeFilter(op: .startsWith) let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] LIKE 'test%'") == true) + #expect(result?.contains("[name] LIKE N'test%'") == true) #expect(result?.contains("ESCAPE") == true) } @@ -75,7 +78,7 @@ struct FilterSQLGeneratorMSSQLTests { func endsWithOperator() { let filter = makeFilter(op: .endsWith) let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] LIKE '%test'") == true) + #expect(result?.contains("[name] LIKE N'%test'") == true) #expect(result?.contains("ESCAPE") == true) } @@ -130,7 +133,7 @@ struct FilterSQLGeneratorMSSQLTests { func singleQuoteEscaping() { let filter = makeFilter(column: "name", op: .equal, value: "O'Brien") let result = generator.generateCondition(from: filter) - #expect(result == "[name] = 'O''Brien'") + #expect(result == "[name] = N'O''Brien'") } // MARK: - WHERE Clause Tests @@ -144,7 +147,7 @@ struct FilterSQLGeneratorMSSQLTests { let result = generator.generateWhereClause(from: filters, logicMode: .and) #expect(result.contains("WHERE")) #expect(result.contains("AND")) - #expect(result.contains("[name] = 'Alice'")) + #expect(result.contains("[name] = N'Alice'")) #expect(result.contains("[age] > 18")) } @@ -157,8 +160,8 @@ struct FilterSQLGeneratorMSSQLTests { let result = generator.generateWhereClause(from: filters, logicMode: .or) #expect(result.contains("WHERE")) #expect(result.contains("OR")) - #expect(result.contains("[name] = 'Alice'")) - #expect(result.contains("[name] = 'Bob'")) + #expect(result.contains("[name] = N'Alice'")) + #expect(result.contains("[name] = N'Bob'")) } // MARK: - Identifier Quoting Tests @@ -169,4 +172,45 @@ struct FilterSQLGeneratorMSSQLTests { let result = generator.generateCondition(from: filter) #expect(result?.hasPrefix("[user_name]") == true) } + + // MARK: - Unicode Literals + + @Test("A non-ASCII value is an nvarchar literal, so a non-Unicode collation cannot flatten it") + func nonAsciiValueIsANationalLiteral() { + let filter = makeFilter(op: .equal, value: "日本語メール") + #expect(generator.generateCondition(from: filter) == "[name] = N'日本語メール'") + } + + @Test("Every value-carrying operator writes a national literal") + func everyValueOperatorWritesANationalLiteral() { + let operators: [FilterOperator] = [ + .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, .regex + ] + for op in operators { + let result = generator.generateCondition(from: makeFilter(op: op, value: "メール")) ?? "" + let body = result.replacingOccurrences(of: " ESCAPE '!'", with: "") + #expect(body.contains("N'"), "\(op) wrote no national literal") + #expect(!body.contains(" '"), "\(op) wrote a plain literal: \(result)") + } + } + + @Test("An IN list prefixes every element") + func inListPrefixesEveryElement() { + let filter = makeFilter(op: .inList, value: "メール,alpha") + let result = generator.generateCondition(from: filter) + #expect(result == "[name] IN (N'メール', N'alpha')") + } + + @Test("Numbers and NULL never take the prefix") + func numbersAndNullAreNotPrefixed() { + #expect(generator.generateCondition(from: makeFilter(column: "age", op: .greaterThan, value: "30")) + == "[age] > 30") + #expect(generator.generateCondition(from: makeFilter(op: .isNull)) == "[name] IS NULL") + } + + @Test("A driver with no prefix is untouched") + func otherEnginesKeepPlainLiterals() { + let plain = FilterSQLGenerator(dialect: Self.mssqlDialect) + #expect(plain.generateCondition(from: makeFilter(op: .equal)) == "[name] = 'test'") + } } diff --git a/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift b/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift index 94c3f87cf8..61720ad6e9 100644 --- a/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift +++ b/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift @@ -33,7 +33,8 @@ struct ForeignKeyLookupQueryTests { key: ForeignKeyLookupColumn? = nil, label: ForeignKeyLookupColumn?, term: String, - dialect: SQLDialectDescriptor? = nil + dialect: SQLDialectDescriptor? = nil, + stringLiteralPrefix: String = "" ) -> String? { ForeignKeyLookupQuery.rows( quotedTable: "\"Artist\"", @@ -41,6 +42,7 @@ struct ForeignKeyLookupQueryTests { label: label, searchTerm: term, dialect: dialect ?? self.dialect(), + stringLiteralPrefix: stringLiteralPrefix, quoteIdentifier: quote ) } diff --git a/TableProTests/Core/Database/ForeignKeyPreviewQueryTests.swift b/TableProTests/Core/Database/ForeignKeyPreviewQueryTests.swift index dec854a6e0..d6db671339 100644 --- a/TableProTests/Core/Database/ForeignKeyPreviewQueryTests.swift +++ b/TableProTests/Core/Database/ForeignKeyPreviewQueryTests.swift @@ -52,6 +52,7 @@ struct ForeignKeyPreviewQueryTests { quotedTable: "[dbo].[customers]", quotedColumn: "[id]", escapedValue: "42", + stringLiteralPrefix: "", dialect: dialect(paginationStyle: .offsetFetch) ) #expect( @@ -66,8 +67,24 @@ struct ForeignKeyPreviewQueryTests { quotedTable: "\"users\"", quotedColumn: "\"name\"", escapedValue: "O''Brien", + stringLiteralPrefix: "", dialect: dialect(paginationStyle: .limit) ) #expect(sql == "SELECT * FROM \"users\" WHERE \"name\" = 'O''Brien' LIMIT 1") } + + /// A plain literal is a `varchar` on SQL Server, so a key holding non-Latin text matched the + /// row whose own text the server had already damaged, and the popover reported the real row as + /// missing. + @Test("The key literal carries the engine's prefix") + func keyLiteralCarriesThePrefix() { + let sql = ForeignKeyPreviewQuery.singleRow( + quotedTable: "[dbo].[customers]", + quotedColumn: "[name]", + escapedValue: "日本語", + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(.mssql), + dialect: dialect(paginationStyle: .offsetFetch) + ) + #expect(sql.contains("[name] = N'日本語'")) + } } diff --git a/TableProTests/Core/Database/GeometryWKBParserTests.swift b/TableProTests/Core/Database/GeometryWKBParserTests.swift index 42061f9650..ba80e74719 100644 --- a/TableProTests/Core/Database/GeometryWKBParserTests.swift +++ b/TableProTests/Core/Database/GeometryWKBParserTests.swift @@ -77,7 +77,6 @@ private func wkbPolygon(_ rings: [[(Double, Double)]]) -> [UInt8] { @Suite("GeometryWKBParser") struct GeometryWKBParserTests { - @Test("Point: little-endian binary produces WKT") func testPoint() { let data = mysqlGeometry(wkb: wkbPoint(1.0, 2.0)) @@ -177,246 +176,3 @@ struct GeometryWKBParserTests { #expect(result == "POINT(100.0 200.0)") } } - -// MARK: - Local Copy of GeometryWKBParser - -// Copied from Plugins/MySQLDriverPlugin/GeometryWKBParser.swift -// because the plugin is a bundle target and cannot be imported with @testable import. - -private enum GeometryWKBParser { - static func parse(_ data: Data) -> String { - guard data.count >= 9 else { - return hexString(data) - } - - let wkbData = data.dropFirst(4) - var offset = wkbData.startIndex - return parseWKBGeometry(wkbData, offset: &offset) ?? hexString(data) - } - - static func parse(_ buffer: UnsafeRawBufferPointer) -> String { - let data = Data(buffer) - return parse(data) - } - - private static func parseWKBGeometry(_ data: Data.SubSequence, offset: inout Data.Index) -> String? { - guard offset < data.endIndex else { return nil } - - let byteOrder = data[offset] - let littleEndian = byteOrder == 0x01 - offset = data.index(after: offset) - - guard let typeCode = readUInt32(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - - switch typeCode { - case 1: - return parsePoint(data, offset: &offset, littleEndian: littleEndian) - case 2: - return parseLineString(data, offset: &offset, littleEndian: littleEndian) - case 3: - return parsePolygon(data, offset: &offset, littleEndian: littleEndian) - case 4: - return parseMultiPoint(data, offset: &offset, littleEndian: littleEndian) - case 5: - return parseMultiLineString(data, offset: &offset, littleEndian: littleEndian) - case 6: - return parseMultiPolygon(data, offset: &offset, littleEndian: littleEndian) - case 7: - return parseGeometryCollection(data, offset: &offset, littleEndian: littleEndian) - default: - return nil - } - } - - private static func parsePoint( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let x = readFloat64(data, offset: &offset, littleEndian: littleEndian), - let y = readFloat64(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - return "POINT(\(formatCoord(x)) \(formatCoord(y)))" - } - - private static func parseLineString( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let points = readPointList(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - return "LINESTRING(\(points))" - } - - private static func parsePolygon( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let numRings = readUInt32(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - var rings: [String] = [] - for _ in 0 ..< numRings { - guard let points = readPointList(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - rings.append("(\(points))") - } - return "POLYGON(\(rings.joined(separator: ", ")))" - } - - private static func parseMultiPoint( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let numGeoms = readUInt32(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - var points: [String] = [] - for _ in 0 ..< numGeoms { - guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil } - if geom.hasPrefix("POINT("), geom.hasSuffix(")") { - let ns = geom as NSString - points.append(ns.substring(with: NSRange(location: 6, length: ns.length - 7))) - } else { - points.append(geom) - } - } - return "MULTIPOINT(\(points.joined(separator: ", ")))" - } - - private static func parseMultiLineString( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let numGeoms = readUInt32(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - var lineStrings: [String] = [] - for _ in 0 ..< numGeoms { - guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil } - if geom.hasPrefix("LINESTRING("), geom.hasSuffix(")") { - let ns = geom as NSString - lineStrings.append("(\(ns.substring(with: NSRange(location: 11, length: ns.length - 12))))") - } else { - lineStrings.append(geom) - } - } - return "MULTILINESTRING(\(lineStrings.joined(separator: ", ")))" - } - - private static func parseMultiPolygon( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let numGeoms = readUInt32(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - var polygons: [String] = [] - for _ in 0 ..< numGeoms { - guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil } - if geom.hasPrefix("POLYGON("), geom.hasSuffix(")") { - let ns = geom as NSString - polygons.append("(\(ns.substring(with: NSRange(location: 8, length: ns.length - 9))))") - } else { - polygons.append(geom) - } - } - return "MULTIPOLYGON(\(polygons.joined(separator: ", ")))" - } - - private static func parseGeometryCollection( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let numGeoms = readUInt32(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - var geoms: [String] = [] - for _ in 0 ..< numGeoms { - guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil } - geoms.append(geom) - } - return "GEOMETRYCOLLECTION(\(geoms.joined(separator: ", ")))" - } - - private static func readUInt32( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> UInt32? { - let endOffset = data.index(offset, offsetBy: 4, limitedBy: data.endIndex) ?? data.endIndex - guard data.distance(from: offset, to: endOffset) == 4 else { return nil } - - let bytes = data[offset ..< endOffset] - offset = endOffset - - if littleEndian { - return bytes.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self).littleEndian } - } else { - return bytes.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self).bigEndian } - } - } - - private static func readFloat64( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> Double? { - let endOffset = data.index(offset, offsetBy: 8, limitedBy: data.endIndex) ?? data.endIndex - guard data.distance(from: offset, to: endOffset) == 8 else { return nil } - - let bytes = data[offset ..< endOffset] - offset = endOffset - - let bits: UInt64 - if littleEndian { - bits = bytes.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self).littleEndian } - } else { - bits = bytes.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self).bigEndian } - } - return Double(bitPattern: bits) - } - - private static func readPointList( - _ data: Data.SubSequence, - offset: inout Data.Index, - littleEndian: Bool - ) -> String? { - guard let numPoints = readUInt32(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - var coords: [String] = [] - for _ in 0 ..< numPoints { - guard let x = readFloat64(data, offset: &offset, littleEndian: littleEndian), - let y = readFloat64(data, offset: &offset, littleEndian: littleEndian) else { - return nil - } - coords.append("\(formatCoord(x)) \(formatCoord(y))") - } - return coords.joined(separator: ", ") - } - - private static func formatCoord(_ value: Double) -> String { - if value == value.rounded() && abs(value) < 1e15 { - return String(format: "%.1f", value) - } - let formatted = String(format: "%.15g", value) - return formatted - } - - static func hexString(_ data: Data) -> String { - if data.isEmpty { return "" } - return "0x" + data.map { String(format: "%02X", $0) }.joined() - } -} diff --git a/TableProTests/Core/Database/LostConnectionReportingTests.swift b/TableProTests/Core/Database/LostConnectionReportingTests.swift new file mode 100644 index 0000000000..ba532338b1 --- /dev/null +++ b/TableProTests/Core/Database/LostConnectionReportingTests.swift @@ -0,0 +1,59 @@ +// +// LostConnectionReportingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Plugin driver adapter and a lost connection") +struct PluginDriverAdapterLostConnectionTests { + @Test("the adapter forwards the driver's lost connection and leaves its own status alone") + func forwardsTheFactWithoutRewritingStatus() async throws { + let plugin = FakeMSSQLPluginDriver() + let adapter = PluginDriverAdapter(connection: TestFixtures.makeConnection(), pluginDriver: plugin) + try await adapter.connect() + + plugin.hasLostConnection = true + + #expect(adapter.hasLostConnection) + #expect(adapter.status == .connected) + } +} + +@Suite("Metadata pool and a lost connection", .serialized) +@MainActor +struct MetadataConnectionPoolLostEntryTests { + /// The shared pool on purpose: this is the one production uses, and opening a real entry on it + /// is what starts its sweeper. + @Test("a pooled driver that reported a lost connection is replaced instead of reused") + func lostEntryIsRebuilt() async throws { + FakeMSSQLPluginRegistration.registerIfNeeded() + var connection = TestFixtures.makeConnection(name: "Prod") + connection.type = DatabaseType(rawValue: FakeMSSQLPlugin.databaseTypeId) + var session = ConnectionSession(connection: connection) + session.status = .connected + session.driver = MockDatabaseDriver(connection: connection) + DatabaseManager.shared.injectSession(session, for: connection.id) + let pool = MetadataConnectionPool.shared + defer { + pool.closeAll(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + let scope = DatabaseScope(connectionId: connection.id, database: connection.database, schema: nil) + let plugin = FakeMSSQLPluginDriver() + let lost = PluginDriverAdapter(connection: connection, pluginDriver: plugin) + try await lost.connect() + pool.injectEntry(lost, scope: scope) + plugin.hasLostConnection = true + + let reused = try await pool.withDriver(scope: scope) { driver in driver === lost } + + #expect(!reused) + #expect(plugin.disconnectCallCount == 1) + #expect(pool.hasSweeper) + } +} diff --git a/TableProTests/Core/Database/PostgreSQLDumpToolLocatorTests.swift b/TableProTests/Core/Database/PostgreSQLDumpToolLocatorTests.swift new file mode 100644 index 0000000000..a782e5e7ad --- /dev/null +++ b/TableProTests/Core/Database/PostgreSQLDumpToolLocatorTests.swift @@ -0,0 +1,177 @@ +// +// PostgreSQLDumpToolLocatorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("PostgreSQLDumpToolLocator") +struct PostgreSQLDumpToolLocatorTests { + private func makeInstall(root: URL, name: String, binary: String, fileManager: FileManager) throws { + let bin = root.appendingPathComponent("\(name)/bin", isDirectory: true) + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + let tool = bin.appendingPathComponent(binary) + try Data("#!/bin/sh\n".utf8).write(to: tool) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path) + } + + private func temporaryRoot() throws -> URL { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("pg-locator-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + @Test("Only postgresql and libpq installs count under an opt root") + func filtersOptRootEntries() throws { + let base = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: base) } + let optRoot = base.appendingPathComponent("opt", isDirectory: true) + for name in ["postgresql@14", "libpq", "libpq@18", "mysql-client", "redis"] { + try makeInstall(root: optRoot, name: name, binary: "pg_dump", fileManager: .default) + } + + let found = PostgreSQLDumpToolLocator.installedPaths(binary: "pg_dump", roots: [optRoot.path]) + #expect(found.map { URL(fileURLWithPath: $0).pathComponents.dropLast(2).last } == ["libpq", "libpq@18", "postgresql@14"]) + } + + @Test("Every entry counts under a versioned root that is not an opt directory") + func keepsVersionedRootEntries() throws { + let base = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: base) } + let versions = base.appendingPathComponent("Versions", isDirectory: true) + for name in ["13", "17"] { + try makeInstall(root: versions, name: name, binary: "pg_dump", fileManager: .default) + } + + let found = PostgreSQLDumpToolLocator.installedPaths(binary: "pg_dump", roots: [versions.path]) + #expect(found.count == 2) + } + + @Test("Roots are searched in order and a symlinked duplicate is listed once") + func dedupesSymlinkedInstall() throws { + let base = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: base) } + let first = base.appendingPathComponent("opt", isDirectory: true) + let second = base.appendingPathComponent("Versions", isDirectory: true) + try makeInstall(root: first, name: "postgresql@17", binary: "pg_dump", fileManager: .default) + try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + at: second.appendingPathComponent("latest"), + withDestinationURL: first.appendingPathComponent("postgresql@17") + ) + + let found = PostgreSQLDumpToolLocator.installedPaths(binary: "pg_dump", roots: [first.path, second.path]) + #expect(found.count == 1) + #expect(found.first?.hasPrefix(first.path) == true) + } + + @Test("With no server version the PATH binary is taken as it always was") + func unknownServerTakesPathBinary() { + let selection = PostgreSQLDumpToolLocator.select( + binary: "pg_dump", + serverVersion: nil, + roots: [], + pathBinary: { _ in "/usr/bin/pg_dump" }, + probe: { _ in .known(PostgreSQLServerVersion(number: 170_011)) } + ) + #expect(selection == .found(path: "/usr/bin/pg_dump")) + } + + @Test("Nothing installed is missing, not incompatible") + func nothingInstalledIsMissing() { + let selection = PostgreSQLDumpToolLocator.select( + binary: "pg_dump", + serverVersion: "9.1.24", + roots: [], + pathBinary: { _ in nil }, + probe: { _ in .unknown } + ) + #expect(selection == .missing) + } + + @Test("An installed tool that cannot reach the server is incompatible, and the message names both") + func incompatibleNamesTheVersions() { + let selection = PostgreSQLDumpToolLocator.select( + binary: "pg_dump", + serverVersion: "9.1.24", + roots: [], + pathBinary: { _ in "/opt/homebrew/bin/pg_dump" }, + probe: { _ in .known(PostgreSQLServerVersion(number: 170_011)) } + ) + guard case .incompatible(let message) = selection else { + Issue.record("expected an incompatible selection, got \(selection)") + return + } + #expect(message.contains("PostgreSQL 9.1")) + #expect(message.contains("pg_dump 9.3 to 14")) + #expect(message.contains("17.11")) + } + + @Test("A binary whose version cannot be read is used rather than hidden") + func unreadableVersionIsStillUsed() { + let selection = PostgreSQLDumpToolLocator.select( + binary: "pg_dump", + serverVersion: "9.1.24", + roots: [], + pathBinary: { _ in "/usr/local/bin/pg_dump" }, + probe: { _ in .unknown } + ) + #expect(selection == .found(path: "/usr/local/bin/pg_dump")) + } + + @Test("A compatible install beats both the PATH binary and an unreadable one") + func compatibleInstallWins() throws { + let base = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: base) } + let optRoot = base.appendingPathComponent("opt", isDirectory: true) + try makeInstall(root: optRoot, name: "postgresql@14", binary: "pg_dump", fileManager: .default) + try makeInstall(root: optRoot, name: "postgresql@12", binary: "pg_dump", fileManager: .default) + + let selection = PostgreSQLDumpToolLocator.select( + binary: "pg_dump", + serverVersion: "9.1.24", + roots: [optRoot.path], + pathBinary: { _ in "/opt/homebrew/bin/pg_dump" }, + probe: { path in + if path.contains("postgresql@14") { return .known(PostgreSQLServerVersion(number: 140_013)) } + if path.contains("postgresql@12") { return .known(PostgreSQLServerVersion(number: 120_022)) } + return .known(PostgreSQLServerVersion(number: 170_011)) + } + ) + #expect(selection == .found(path: "\(optRoot.path)/postgresql@14/bin/pg_dump")) + } + + @Test("pg_restore is matched to the server by the same rule as pg_dump") + func restoreBinaryFollowsTheSameRule() { + let selection = PostgreSQLDumpToolLocator.select( + binary: "pg_restore", + serverVersion: "9.1.24", + roots: [], + pathBinary: { _ in "/opt/homebrew/bin/pg_restore" }, + probe: { _ in .known(PostgreSQLServerVersion(number: 170_011)) } + ) + guard case .incompatible(let message) = selection else { + Issue.record("expected an incompatible selection, got \(selection)") + return + } + #expect(message.contains("pg_restore")) + #expect(!message.contains("pg_dump")) + } + + @Test("A probe that never answers is abandoned rather than left to hang") + func versionProbeTimesOut() throws { + let base = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: base) } + let script = base.appendingPathComponent("hang.sh") + try Data("#!/bin/sh\nsleep 30\n".utf8).write(to: script) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + + let started = Date() + let probed = PostgreSQLDumpToolLocator.probeVersion(of: script.path, timeout: 0.5) + #expect(probed == .unknown) + #expect(Date().timeIntervalSince(started) < 5) + } +} diff --git a/TableProTests/Core/Database/PostgreSQLServerVersionTests.swift b/TableProTests/Core/Database/PostgreSQLServerVersionTests.swift new file mode 100644 index 0000000000..84935ad085 --- /dev/null +++ b/TableProTests/Core/Database/PostgreSQLServerVersionTests.swift @@ -0,0 +1,154 @@ +// +// PostgreSQLServerVersionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("PostgreSQLServerVersion") +struct PostgreSQLServerVersionTests { + @Test( + "Parses the strings drivers and tools report into server_version_num form", + arguments: [ + ("9.1.24", 90_124), + ("9.2.23", 90_223), + ("9.6.24", 90_624), + ("10.21", 100_021), + ("17.11", 170_011), + ("17.11 (Homebrew)", 170_011), + ("pg_dump (PostgreSQL) 17.11 (Homebrew)", 170_011), + ("pg_dump (PostgreSQL) 9.1.24", 90_124), + ("pg_dump (PostgreSQL) 12.22 (Debian 12.22-1.pgdg120+1)", 120_022), + ("10beta2", 100_000), + ("13.0.0", 130_000), + ("8.0.2", 80_002), + ("9.1", 90_100) + ] + ) + func parses(text: String, number: Int) { + #expect(PostgreSQLServerVersion(text)?.number == number) + } + + @Test("Text with no version number is not a version", arguments: ["", "PostgreSQL", "pg_dump (PostgreSQL)", "0.0"]) + func rejects(text: String) { + #expect(PostgreSQLServerVersion(text) == nil) + } + + @Test("A nil version string is not a version") + func rejectsNil() { + #expect(PostgreSQLServerVersion(nil) == nil) + } + + @Test("Major release names follow PostgreSQL's two numbering schemes") + func majorReleaseNames() { + #expect(PostgreSQLServerVersion(number: 90_124).majorReleaseName == "9.1") + #expect(PostgreSQLServerVersion(number: 170_011).majorReleaseName == "17") + #expect(PostgreSQLServerVersion(number: 90_124).fullName == "9.1.24") + #expect(PostgreSQLServerVersion(number: 170_011).fullName == "17.11") + } + + @Test("Major release numbers compare the release, not the patch level") + func majorReleaseNumbers() { + #expect(PostgreSQLServerVersion(number: 90_124).majorReleaseNumber == 901) + #expect(PostgreSQLServerVersion(number: 170_011).majorReleaseNumber == 1_700) + #expect(PostgreSQLServerVersion(number: 170_002).majorReleaseNumber == 1_700) + } +} + +@Suite("PostgreSQLDumpToolCompatibility") +struct PostgreSQLDumpToolCompatibilityTests { + private func version(_ text: String) throws -> PostgreSQLServerVersion { + try #require(PostgreSQLServerVersion(text)) + } + + @Test( + "pg_dump refuses servers newer than itself and, from 15, servers older than 9.2", + arguments: [ + ("9.1.24", "17.11", false), + ("9.1.24", "18.6", false), + ("9.1.24", "14.13", true), + ("9.1.24", "12.22", true), + ("9.2.23", "17.11", true), + ("9.6.24", "18.6", true), + ("17.11", "17.2", true), + ("17.11", "16.4", false), + ("12.22", "9.6.24", false), + ("9.1.24", "9.2.23", false), + ("9.1.24", "9.3.25", true) + ] + ) + func canDump(server: String, tool: String, expected: Bool) throws { + #expect(PostgreSQLDumpToolCompatibility.canDump(server: try version(server), with: try version(tool)) == expected) + } + + @Test("The tool found on PATH wins when it can dump the server") + func prefersPathTool() throws { + let preferred = PostgreSQLDumpToolCompatibility.Candidate(path: "/opt/homebrew/bin/pg_dump", version: try version("17.11")) + let other = PostgreSQLDumpToolCompatibility.Candidate(path: "/opt/homebrew/opt/libpq/bin/pg_dump", version: try version("18.6")) + let chosen = PostgreSQLDumpToolCompatibility.choose(for: try version("12.22"), preferred: preferred, others: [other]) + #expect(chosen == preferred) + } + + @Test("An older installed tool is chosen when the PATH tool refuses the server") + func fallsBackToOlderTool() throws { + let preferred = PostgreSQLDumpToolCompatibility.Candidate(path: "/opt/homebrew/bin/pg_dump", version: try version("17.11")) + let fourteen = PostgreSQLDumpToolCompatibility.Candidate( + path: "/opt/homebrew/opt/postgresql@14/bin/pg_dump", version: try version("14.13") + ) + let twelve = PostgreSQLDumpToolCompatibility.Candidate( + path: "/Applications/Postgres.app/Contents/Versions/12/bin/pg_dump", version: try version("12.22") + ) + let chosen = PostgreSQLDumpToolCompatibility.choose( + for: try version("9.1.24"), preferred: preferred, others: [twelve, fourteen] + ) + #expect(chosen == fourteen) + } + + @Test("Nothing is chosen when no installed tool can dump the server") + func noCompatibleTool() throws { + let preferred = PostgreSQLDumpToolCompatibility.Candidate(path: "/opt/homebrew/bin/pg_dump", version: try version("17.11")) + #expect(PostgreSQLDumpToolCompatibility.choose(for: try version("9.1.24"), preferred: preferred, others: []) == nil) + } + + @Test("The refusal for a 9.1 server names the tool, the 9.3 to 14 range and what was found") + func refusalForOldServer() throws { + let found = [ + PostgreSQLDumpToolCompatibility.Candidate(path: "/a", version: try version("18.6")), + PostgreSQLDumpToolCompatibility.Candidate(path: "/b", version: try version("17.11")) + ] + let server = try version("9.1.24") + let message = PostgreSQLDumpToolCompatibility.refusal(for: server, found: found, toolName: "pg_dump") + #expect(message.contains("PostgreSQL 9.1 needs pg_dump 9.3 to 14.")) + #expect(message.contains("brew install postgresql@14")) + #expect(message.contains("Found pg_dump 17.11 and 18.6.")) + } + + @Test("The refusal for a 7.x server stops at 9.6, the newest tool that reaches it") + func refusalForPreEightServer() throws { + let found = [PostgreSQLDumpToolCompatibility.Candidate(path: "/a", version: try version("17.11"))] + let server = try version("7.4.30") + let message = PostgreSQLDumpToolCompatibility.refusal(for: server, found: found, toolName: "pg_dump") + #expect(message.contains("PostgreSQL 7.4 needs pg_dump 9.3 to 9.6.")) + #expect(!message.contains("postgresql@14")) + } + + @Test("The refusal names pg_restore when it is pg_restore that cannot reach the server") + func refusalNamesTheRestoreTool() throws { + let found = [PostgreSQLDumpToolCompatibility.Candidate(path: "/a", version: try version("17.11"))] + let server = try version("9.1.24") + let message = PostgreSQLDumpToolCompatibility.refusal(for: server, found: found, toolName: "pg_restore") + #expect(message.contains("PostgreSQL 9.1 needs pg_restore 9.3 to 14.")) + #expect(message.contains("Found pg_restore 17.11.")) + } + + @Test("The refusal for a server newer than every tool names that release") + func refusalForNewServer() throws { + let found = [PostgreSQLDumpToolCompatibility.Candidate(path: "/a", version: try version("17.11"))] + let server = try version("19.0") + let message = PostgreSQLDumpToolCompatibility.refusal(for: server, found: found, toolName: "pg_dump") + #expect(message.contains("PostgreSQL 19 needs pg_dump 19 or later.")) + #expect(message.contains("brew install libpq")) + } +} diff --git a/TableProTests/Core/Database/SQLStringLiteralPrefixTests.swift b/TableProTests/Core/Database/SQLStringLiteralPrefixTests.swift new file mode 100644 index 0000000000..757388cc3e --- /dev/null +++ b/TableProTests/Core/Database/SQLStringLiteralPrefixTests.swift @@ -0,0 +1,29 @@ +// +// SQLStringLiteralPrefixTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SQL String Literal Prefix") +struct SQLStringLiteralPrefixTests { + @Test("SQL Server asks for a national literal") + func sqlServerAsksForANationalLiteral() { + #expect(SQLStringLiteralPrefix.forDatabaseType(.mssql) == "N") + } + + @Test("Every other engine writes a plain literal") + func otherEnginesWritePlainLiterals() { + for type in DatabaseType.allKnownTypes where type != .mssql { + #expect(SQLStringLiteralPrefix.forDatabaseType(type) == "", "\(type.rawValue) asked for a prefix") + } + } + + @Test("An unknown type from a future plugin writes a plain literal") + func unknownTypesWritePlainLiterals() { + #expect(SQLStringLiteralPrefix.forDatabaseType(DatabaseType(rawValue: "SomeFutureEngine")) == "") + #expect(SQLStringLiteralPrefix.forDatabaseType(nil) == "") + } +} diff --git a/TableProTests/Core/Database/TableDDLComposerTests.swift b/TableProTests/Core/Database/TableDDLComposerTests.swift new file mode 100644 index 0000000000..3f54e745f5 --- /dev/null +++ b/TableProTests/Core/Database/TableDDLComposerTests.swift @@ -0,0 +1,79 @@ +// +// TableDDLComposerTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Table DDL composition") +struct TableDDLComposerTests { + private let tableDDL = "CREATE TABLE \"app\".\"orders\" (\n id integer\n)" + private let comment = "COMMENT ON TABLE \"app\".\"orders\" IS 'Orders table'" + private let index = "CREATE INDEX idx_orders_day ON \"app\".\"orders\" (day)" + + private func offset(of needle: String, in text: String) throws -> Int { + let range = try #require(text.range(of: needle), "\(needle) missing from the composition") + return text.distance(from: text.startIndex, to: range.lowerBound) + } + + @Test("Comments sit after the table statement and before the indexes, as a dump writes them") + func commentsSitBetweenTheTableAndItsIndexes() throws { + let composed = TableDDLComposer.compose( + tableDDL: tableDDL, indexDDL: [index], commentDDL: [comment]) + + #expect(try offset(of: "CREATE TABLE", in: composed) < offset(of: "COMMENT ON TABLE", in: composed)) + #expect(try offset(of: "COMMENT ON TABLE", in: composed) < offset(of: "CREATE INDEX", in: composed)) + } + + @Test("Every statement is terminated once, even one the driver already terminated") + func statementsAreTerminatedOnce() { + let composed = TableDDLComposer.compose( + tableDDL: tableDDL, + indexDDL: ["\(index);"], + commentDDL: [comment]) + + #expect(composed.contains("IS 'Orders table';")) + #expect(!composed.contains("IS 'Orders table';;")) + #expect(composed.contains("(day);")) + #expect(!composed.contains("(day);;")) + } + + @Test("The preamble stays first") + func preambleStaysFirst() throws { + let composed = TableDDLComposer.compose( + tableDDL: tableDDL, + indexDDL: [], + commentDDL: [comment], + preamble: "CREATE SEQUENCE orders_id_seq;") + + #expect(try offset(of: "CREATE SEQUENCE", in: composed) < offset(of: "CREATE TABLE", in: composed)) + #expect(try offset(of: "CREATE TABLE", in: composed) < offset(of: "COMMENT ON TABLE", in: composed)) + } + + @Test("An empty comment list composes exactly as it did before comments existed") + func emptyCommentListChangesNothing() { + let withComments = TableDDLComposer.compose( + tableDDL: tableDDL, indexDDL: [index], commentDDL: []) + let withoutComments = TableDDLComposer.compose(tableDDL: tableDDL, indexDDL: [index]) + + #expect(withComments == withoutComments) + } + + @Test("Comments alone still terminate the table statement and stand in their own block") + func commentsAloneComposeWithoutIndexes() { + let composed = TableDDLComposer.compose( + tableDDL: tableDDL, indexDDL: [], commentDDL: [comment]) + + #expect(composed == "\(tableDDL);\n\n\(comment);") + } + + @Test("A blank comment statement is dropped rather than written as an empty line") + func blankStatementsAreDropped() { + let composed = TableDDLComposer.compose( + tableDDL: tableDDL, indexDDL: [], commentDDL: ["", " \n"]) + + #expect(composed == tableDDL) + } +} diff --git a/TableProTests/Core/MCP/MCPServerDashboardPayloadTests.swift b/TableProTests/Core/MCP/MCPServerDashboardPayloadTests.swift new file mode 100644 index 0000000000..f1eb5e854d --- /dev/null +++ b/TableProTests/Core/MCP/MCPServerDashboardPayloadTests.swift @@ -0,0 +1,62 @@ +// +// MCPServerDashboardPayloadTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("get_server_dashboard payload") +struct MCPServerDashboardPayloadTests { + @Test("Panels that all read come back without an errors object") + func noFailures() throws { + let payload = try MCPConnectionBridge.dashboardPayload( + panels: ["sessions": .array([]), "metrics": .array([])], + failures: [:] + ) + #expect(payload == .object(["sessions": .array([]), "metrics": .array([])])) + } + + @Test("A panel that failed is reported under errors instead of as an empty list") + func partialFailure() throws { + let payload = try MCPConnectionBridge.dashboardPayload( + panels: ["slow_queries": .array([])], + failures: ["sessions": MCPConnectionBridge.dashboardPanelFailure( + panel: "sessions", error: DatabaseAccessError.dataSourceError("column \"pid\" does not exist") + )] + ) + let errors = payload["errors"]?["sessions"]?.stringValue + #expect(errors == "The server did not answer the sessions panel.") + #expect(errors?.contains("pid") == false) + #expect(payload["slow_queries"] == .array([])) + } + + @Test("A panel failure never carries the server's own words to the client") + func panelFailureIsFixedText() { + let message = MCPConnectionBridge.dashboardPanelFailure( + panel: "slow_queries", + error: DatabaseAccessError.dataSourceError("permission denied for table secrets") + ) + #expect(message == "The server did not answer the slow queries panel.") + #expect(!message.contains("secrets")) + } + + @Test("The output schema declares the errors object, one message per panel") + func outputSchemaDeclaresErrors() { + let errors = ServerDashboardTool.outputSchema?["properties"]?["errors"]?["properties"] + for panel in ServerDashboardTool.panelNames { + #expect(errors?[panel]?["type"]?.stringValue == "string") + } + } + + @Test("When every requested panel fails the call fails") + func everyPanelFails() { + #expect(throws: DatabaseAccessError.self) { + _ = try MCPConnectionBridge.dashboardPayload( + panels: [:], + failures: ["sessions": "boom", "metrics": "bang"] + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift new file mode 100644 index 0000000000..de73e8483d --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift @@ -0,0 +1,78 @@ +// +// MCPStatementConsentGuardTests.swift +// TableProTests +// +// A client that declares elicitation answers its own approval prompt, and TablePro then treats the +// statement as pre-cleared. Only an admin-scoped token may buy out the Mac dialog that way. The +// enforcement lived in an overload nothing called, so the source scan is half the point: the +// regression is a missing argument, and the behavioural half cannot see one. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP statement consent guard") +struct MCPStatementConsentGuardTests { + private static let gateSource: String = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 6 { + url.deleteLastPathComponent() + } + url.appendPathComponent("TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift") + return (try? String(contentsOf: url, encoding: .utf8)) ?? "" + }() + + private func principal(scopes: Set, tokenId: UUID?) -> MCPPrincipal { + MCPPrincipal( + tokenFingerprint: "test", + tokenId: tokenId, + scopes: scopes, + metadata: MCPPrincipalMetadata(label: "Test", issuedAt: .distantPast, expiresAt: nil) + ) + } + + @Test("A write-scoped token cannot pre-clear the confirmation for itself") + func writeTokenCannotPreClear() { + let effective = MCPAuthPolicy.effectiveCapabilities( + [.mayWrite, .confirmationPreCleared], + for: principal(scopes: [.toolsWrite], tokenId: UUID()) + ) + #expect(!effective.contains(.confirmationPreCleared)) + #expect(!effective.contains(.preCleared)) + #expect(effective.contains(.mayWrite)) + } + + @Test("An anonymous caller cannot pre-clear even holding admin") + func anonymousAdminCannotPreClear() { + let effective = MCPAuthPolicy.effectiveCapabilities( + [.mayWrite, .preCleared, .confirmationPreCleared], + for: principal(scopes: [.toolsWrite, .admin], tokenId: nil) + ) + #expect(!effective.contains(.confirmationPreCleared)) + #expect(!effective.contains(.preCleared)) + } + + @Test("An admin-scoped token keeps its pre-clearance") + func adminTokenKeepsPreClearance() { + let effective = MCPAuthPolicy.effectiveCapabilities( + [.mayWrite, .confirmationPreCleared], + for: principal(scopes: [.toolsWrite, .admin], tokenId: UUID()) + ) + #expect(effective.contains(.confirmationPreCleared)) + } + + /// The stripping only runs on the overload that takes a principal. Calling the other one from + /// the MCP path is the bypass, and it compiles cleanly. + @Test("The MCP statement path always names its principal at the Safe Mode gate") + func statementGatePassesItsPrincipal() throws { + #expect(!Self.gateSource.isEmpty, "MCPStatementGate.swift was not readable") + + let call = try #require( + Self.gateSource.range(of: "checkSafeModeDialog("), + "MCPStatementGate no longer reaches the Safe Mode gate" + ) + let arguments = Self.gateSource[call.upperBound...].prefix(300) + #expect(arguments.contains("principal:"), "MCPStatementGate must pass principal: to checkSafeModeDialog") + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/MCPStatementGateTests.swift b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementGateTests.swift index c4f301f61a..d1e68e4401 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/MCPStatementGateTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementGateTests.swift @@ -175,14 +175,35 @@ struct MCPStatementGateConsentPolicyTests { #expect(!requiresConsent("DELETE FROM users WHERE id = 1", safeMode: .silent)) } - @Test("The preview a user sees is one line and capped") - func previewIsOneCappedLine() { + @Test("The preview a user sees keeps its line breaks") + func previewKeepsLineBreaks() { let preview = MCPStatementGate.preview(of: "SELECT\n\t1\n") - #expect(preview == "SELECT 1") - #expect(!preview.contains("\n")) + #expect(preview == "SELECT\n\t1") + } + + @Test("A statement a user could read is shown whole") + func previewKeepsAReadableStatement() { + let statement = """ + UPDATE accounts + SET balance = 0 + WHERE customer_id = 42 + """ + #expect(MCPStatementGate.preview(of: statement) == statement) + } - let long = MCPStatementGate.preview(of: String(repeating: "a", count: 900)) - #expect((long as NSString).length == 401) - #expect(long.hasSuffix("…")) + /// The cut used to land at 400 characters, which is before the `WHERE` clause of any statement + /// worth confirming, so the user approved a filter they never saw. + @Test("A long statement keeps its filter and is marked as cut") + func previewCapsWellPastAFilter() { + let padding = String(repeating: "a", count: 600) + let statement = "UPDATE accounts SET note = '\(padding)' WHERE customer_id = 42" + let preview = MCPStatementGate.preview(of: statement) + #expect(preview == statement) + #expect(preview.contains("WHERE customer_id = 42")) + + let overLimit = String(repeating: "a", count: MCPStatementGate.previewCharacterLimit + 500) + let cut = MCPStatementGate.preview(of: overLimit) + #expect((cut as NSString).length == MCPStatementGate.previewCharacterLimit + 1) + #expect(cut.hasSuffix("…")) } } diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 03d66c10d8..47bbbafb62 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -419,6 +419,17 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.toggleFilterBar(_:)), context)) } + @Test("Highlight Rules needs a connected data grid with columns") + func highlightRulesNeedsDataGrid() { + var context = MenuValidationContext() + context.canPresentHighlightRules = true + #expect(!enabled(#selector(MainSplitViewController.showHighlightRules(_:)), context)) + context.isConnected = true + #expect(enabled(#selector(MainSplitViewController.showHighlightRules(_:)), context)) + context.canPresentHighlightRules = false + #expect(!enabled(#selector(MainSplitViewController.showHighlightRules(_:)), context)) + } + @Test("Capability flags gate driver-specific commands") func capabilitiesGateCommands() { var context = MenuValidationContext() @@ -445,6 +456,8 @@ struct MainMenuValidationTests { private func capableContext() -> MenuValidationContext { var context = MenuValidationContext() context.canUseTableResultCommands = true + context.canPresentHighlightRules = true + context.canNavigatePages = true context.isQueryTab = true context.hasResultRows = true context.hasQueryText = true @@ -489,7 +502,8 @@ struct MainMenuValidationTests { #selector(MainSplitViewController.openContainerSwitcher(_:)), #selector(MainSplitViewController.showServerDashboard(_:)), #selector(MainSplitViewController.showUsersAndRoles(_:)), - #selector(MainSplitViewController.toggleFilterBar(_:)) + #selector(MainSplitViewController.toggleFilterBar(_:)), + #selector(MainSplitViewController.showHighlightRules(_:)) ] } @@ -623,6 +637,45 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.editViewDefinition(_:)), context)) } + /// The sidebar hides Edit View Definition on a read-only connection. The menu bar used to leave + /// it enabled, so it opened the definition and the save then failed at the gate. + @Test("Editing a view definition is disabled on a read-only connection") + func editViewDefinitionNeedsWriteAccess() { + var context = MenuValidationContext() + context.isConnected = true + context.canEditViewDefinition = true + #expect(enabled(#selector(MainSplitViewController.editViewDefinition(_:)), context)) + context.isReadOnly = true + #expect(!enabled(#selector(MainSplitViewController.editViewDefinition(_:)), context)) + } + + /// Reading a definition writes nothing, so these stay available on a read-only connection, + /// exactly as the sidebar offers them there. + @Test("Show DDL and Copy DDL need a view and survive read-only") + func ddlCommandsFollowTheSelectedObject() { + var context = MenuValidationContext() + context.isConnected = true + #expect(!enabled(#selector(MainSplitViewController.showObjectDDL(_:)), context)) + #expect(!enabled(#selector(MainSplitViewController.copyObjectDDL(_:)), context)) + context.canShowObjectDDL = true + #expect(enabled(#selector(MainSplitViewController.showObjectDDL(_:)), context)) + #expect(enabled(#selector(MainSplitViewController.copyObjectDDL(_:)), context)) + context.isReadOnly = true + #expect(enabled(#selector(MainSplitViewController.showObjectDDL(_:)), context)) + } + + @Test("Refresh and Edit Comment need the driver and the object to support them") + func refreshAndCommentNeedSupport() { + var context = MenuValidationContext() + context.isConnected = true + #expect(!enabled(#selector(MainSplitViewController.refreshMaterializedView(_:)), context)) + #expect(!enabled(#selector(MainSplitViewController.editObjectComment(_:)), context)) + context.canRefreshMaterializedView = true + context.canEditObjectComment = true + #expect(enabled(#selector(MainSplitViewController.refreshMaterializedView(_:)), context)) + #expect(enabled(#selector(MainSplitViewController.editObjectComment(_:)), context)) + } + @Test("Maintenance stays disabled when the driver offers no operations") func maintenanceNeedsOperations() { var context = MenuValidationContext() diff --git a/TableProTests/Core/Plugins/ImportTypeMapperTests.swift b/TableProTests/Core/Plugins/ImportTypeMapperTests.swift index 6c09bba6b8..72c028dbd5 100644 --- a/TableProTests/Core/Plugins/ImportTypeMapperTests.swift +++ b/TableProTests/Core/Plugins/ImportTypeMapperTests.swift @@ -19,6 +19,29 @@ struct ImportTypeMapperTests { #expect(ImportTypeMapper.sqlType(for: .text, databaseType: .postgresql) == "TEXT") } + @Test( + "A JSON field on PostgreSQL takes the richest type the server has", + arguments: [ + (Optional.none, "JSONB"), + (Optional("17.11"), "JSONB"), + (Optional("9.4.26"), "JSONB"), + (Optional("9.3.25"), "JSON"), + (Optional("9.2.23"), "JSON"), + (Optional("9.1.24"), "TEXT") + ] + ) + func postgresJSONFollowsServerVersion(serverVersion: String?, expected: String) { + #expect( + ImportTypeMapper.sqlType(for: .json, databaseType: .postgresql, serverVersion: serverVersion) == expected + ) + } + + @Test("Redshift and CockroachDB keep their JSON mapping whatever version they report") + func postgresForksKeepJSONMapping() { + #expect(ImportTypeMapper.sqlType(for: .json, databaseType: .redshift, serverVersion: "8.0.2") == "JSONB") + #expect(ImportTypeMapper.sqlType(for: .json, databaseType: .cockroachdb, serverVersion: "13.0.0") == "JSONB") + } + @Test("MySQL maps inferred types to native SQL types") func testMySQL() { #expect(ImportTypeMapper.sqlType(for: .integer, databaseType: .mysql) == "BIGINT") diff --git a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift index 1b6e1540fb..202b3bbb14 100644 --- a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift +++ b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift @@ -29,6 +29,15 @@ struct MySQLVariantSupportTests { #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: DatabaseType(rawValue: typeId)) == .plainText) } + @Test("OceanBase keeps a plain EXPLAIN and does not take FORMAT=JSON") + func oceanbaseExplainStaysPlain() throws { + let registry = PluginMetadataRegistry.shared + registry.registerVariant(pluginSnapshot: try Self.mysqlPluginSnapshot(), forTypeId: "OceanBase", primaryTypeId: "MySQL") + let variants = try #require(registry.snapshot(forRegisteredTypeId: "OceanBase")).explainVariants + #expect(variants.map(\.sqlPrefix) == ["EXPLAIN"]) + #expect(variants.allSatisfy { $0.format == .plainText }) + } + @Test("TiDB keeps its type list without the spatial group once the MySQL plugin registers") func tidbColumnTypesSurviveRegistration() throws { let registry = PluginMetadataRegistry.shared @@ -38,6 +47,15 @@ struct MySQLVariantSupportTests { #expect(types["JSON"] == ["JSON"]) } + @Test("OceanBase keeps its type list without the spatial group once the MySQL plugin registers") + func oceanbaseColumnTypesSurviveRegistration() throws { + let registry = PluginMetadataRegistry.shared + registry.registerVariant(pluginSnapshot: try Self.mysqlPluginSnapshot(), forTypeId: "OceanBase", primaryTypeId: "MySQL") + let types = try #require(registry.snapshot(forRegisteredTypeId: "OceanBase")).editor.columnTypesByCategory + #expect(types["Spatial"] == nil) + #expect(types["JSON"] == ["JSON"]) + } + @Test("Databend keeps its own types and case folding once the MySQL plugin registers") func databendEditorSurvivesRegistration() throws { let registry = PluginMetadataRegistry.shared @@ -66,6 +84,7 @@ struct MySQLVariantSupportTests { @Test("TiDB hides the connection limit it ignores; the others keep it") func principalConnectionLimit() { #expect(!PluginManager.shared.supportsPrincipalConnectionLimit(for: .tidb)) + #expect(!PluginManager.shared.supportsPrincipalConnectionLimit(for: .oceanbase)) #expect(PluginManager.shared.supportsPrincipalConnectionLimit(for: .mysql)) #expect(PluginManager.shared.supportsPrincipalConnectionLimit(for: .mariadb)) } @@ -75,6 +94,7 @@ struct MySQLVariantSupportTests { #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .databend).contains("VARIANT")) #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .mysql).isEmpty) #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .tidb).isEmpty) + #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .oceanbase).isEmpty) } @Test("The inspector's function menu offers only what each engine has") @@ -82,6 +102,8 @@ struct MySQLVariantSupportTests { let tidb = SQLFunctionProvider.functions(for: .tidb).map(\.expression) #expect(tidb.contains("CURDATE()")) #expect(tidb.contains("UTC_TIMESTAMP()")) + let oceanbase = SQLFunctionProvider.functions(for: .oceanbase).map(\.expression) + #expect(oceanbase.contains("CURDATE()")) let databend = SQLFunctionProvider.functions(for: .databend).map(\.expression) #expect(databend == ["NOW()", "CURRENT_TIMESTAMP()", "UUID()"]) } @@ -97,6 +119,7 @@ struct MySQLVariantSupportTests { @Test("Databend lexes as the generic dialect, TiDB as MySQL") func lexicalDialects() { #expect(SqlDialect.from(databaseTypeId: "TiDB") == .mysql) + #expect(SqlDialect.from(databaseTypeId: "OceanBase") == .mysql) #expect(SqlDialect.from(databaseTypeId: "Databend") == .generic) } @@ -109,7 +132,9 @@ struct MySQLVariantSupportTests { @Test("TiDB copies as the MySQL type family; Databend does not") func typeFamilies() { #expect(SQLTypeFamily.of(.tidb) == .mysql) + #expect(SQLTypeFamily.of(.oceanbase) == .mysql) #expect(!SQLTypeFamily.needsTranslation(from: .mysql, to: .tidb)) + #expect(!SQLTypeFamily.needsTranslation(from: .mysql, to: .oceanbase)) #expect(SQLTypeFamily.of(.databend) != .mysql) } @@ -118,12 +143,21 @@ struct MySQLVariantSupportTests { #expect(ColumnDefaultVocabulary.options(for: .tidb) == ColumnDefaultVocabulary.options(for: .mysql)) } + @Test("No MySQL variant marks a column immutable") + func variantsHaveNoImmutableColumns() { + #expect(PluginManager.shared.immutableColumns(for: .oceanbase).isEmpty) + #expect(PluginManager.shared.immutableColumns(for: .tidb).isEmpty) + #expect(PluginManager.shared.immutableColumns(for: .mysql).isEmpty) + } + @Test("Neither variant gets a Server Dashboard or a native backup") func dashboardAndBackup() { #expect(ServerDashboardQueryProviderFactory.provider(for: .tidb) == nil) #expect(ServerDashboardQueryProviderFactory.provider(for: .databend) == nil) + #expect(ServerDashboardQueryProviderFactory.provider(for: .oceanbase) == nil) #expect(!NativeDumpRegistry.supports(.tidb)) #expect(!NativeDumpRegistry.supports(.databend)) + #expect(!NativeDumpRegistry.supports(.oceanbase)) } @Test("TiDB compares with TiDB, not with MySQL or MariaDB") @@ -132,14 +166,21 @@ struct MySQLVariantSupportTests { #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .tidb)) #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .tidb, to: .mariadb)) #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .databend)) + #expect(CompareSyncEngineFamily.canGenerateStructureScript(from: .oceanbase, to: .oceanbase)) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .oceanbase)) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .oceanbase, to: .mariadb)) } - @Test("URLs: tidb:// opens TiDB, databend:// is refused") + @Test("URLs: tidb:// opens TiDB, oceanbase:// opens OceanBase, databend:// is refused") func urlSchemes() { guard case .success(let tidb) = ConnectionURLParser.parse("tidb://root@host:4000/test") else { Issue.record("Expected tidb:// to parse"); return } #expect(tidb.type == .tidb) + guard case .success(let oceanbase) = ConnectionURLParser.parse("oceanbase://root%40sys@host:2881/test") else { + Issue.record("Expected oceanbase:// to parse"); return + } + #expect(oceanbase.type == .oceanbase) guard case .failure = ConnectionURLParser.parse("databend://root:pw@host:8000/default") else { Issue.record("Expected databend:// to be refused"); return } diff --git a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift index 39c8841cab..87ec41f017 100644 --- a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift +++ b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift @@ -31,11 +31,26 @@ struct PluginKitABIResilienceTests { #expect(driver.foreignKeyDisableStatements() == nil) #expect(driver.foreignKeyEnableStatements() == nil) #expect(driver.supportedMaintenanceOperations() == nil) + #expect(driver.maintenanceOperations() == nil) #expect(driver.buildExplainQuery("SELECT 1") == nil) #expect(driver.injectRowLimit("SELECT 1", limit: 100) == nil) #expect(driver.defaultExportQuery(table: "users") == nil) #expect(driver.createViewTemplate() == nil) #expect(driver.generateCreateTableSQL(definition: .init(tableName: "users", columns: [], primaryKeyColumns: [])) == nil) + #expect(driver.unsupportedStructureColumnFields.isEmpty) + #expect(driver.unsupportedIndexTypes.isEmpty) + #expect(driver.schemaOperationRefusal(.renameCheckConstraint(from: "a", to: "b")) == nil) + } + + @Test("A driver that answers only the older name list has it lifted into table-like descriptors") + func legacyMaintenanceNamesAreLifted() { + let lifted = PluginMaintenanceOperation.lifting(["VACUUM", "ANALYZE"]) + + #expect(lifted.map(\.name) == ["VACUUM", "ANALYZE"]) + #expect(lifted.allSatisfy { $0.appliesTo == PluginObjectKind.allTableLike }) + #expect(lifted.allSatisfy { $0.scope == .objectOrDatabase }) + #expect(lifted.allSatisfy { $0.options.isEmpty }) + #expect(lifted.allSatisfy { $0.applies(to: .view) }) } @Test("A driver that omits defaulted requirements falls back to the documented asynchronous defaults") @@ -44,6 +59,8 @@ struct PluginKitABIResilienceTests { #expect(try await driver.fetchSchemas().isEmpty) #expect(try await driver.fetchExternalSchemaNames().isEmpty) #expect(try await driver.fetchApproximateRowCount(table: "users", schema: nil) == nil) + #expect(try await driver.fetchIndexDDL(table: "users", schema: nil).isEmpty) + #expect(try await driver.fetchCommentDDL(table: "users", schema: nil).isEmpty) let base = QueryCompletionProfile( resolvedDialect: nil, statementCompletions: [CompletionEntry(label: "SELECT", insertText: "SELECT")], diff --git a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift index 315d0818d9..4cce008edb 100644 --- a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift @@ -49,6 +49,7 @@ struct PluginManagerVariantAccessorTests { "INFORMATION_SCHEMA", "METRICS_SCHEMA", "PERFORMANCE_SCHEMA", "mysql", "sys" ]) #expect(manager.systemDatabaseNames(for: .databend) == ["information_schema", "system"]) + #expect(manager.systemDatabaseNames(for: .oceanbase) == ["information_schema", "mysql", "oceanbase"]) } /// The reason the editor half of this was reported: Redshift has no non-ASCII ILIKE, so it diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index ef6d5a6e67..22290af1e4 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -11,7 +11,7 @@ import Testing /// this registry, `docs/snippets/driver-counts.mdx`, and the marketing site. Nothing at runtime /// reconciles them, and by August 2026 they read 28, 27 and 25 at once. /// -/// The answer is 34, and the reason it once read 28 is worth keeping. Turso is served by +/// The answer is 35, and the reason it once read 28 is worth keeping. Turso is served by /// the libSQL plugin and was the only alias in `reverseTypeIndex` with no curated entry of its /// own, so it was the only type the picker could not offer before its plugin was installed. /// ScyllaDB is the shape every other alias already had: an alias of Cassandra with a curated @@ -24,7 +24,7 @@ import Testing /// `docs/scripts/check-docs-against-source.py` reads the registry and holds the docs half. /// /// The count is taken from the built-in defaults rather than from `allRegisteredTypeIds()`. -/// Both answer 34 under XCTest, where no plugin bundle ever loads, but the registry is a +/// Both answer 35 under XCTest, where no plugin bundle ever loads, but the registry is a /// process-global singleton and suites that register a synthetic type run alongside this one. @MainActor @Suite("PluginMetadataRegistry engine count") @@ -34,7 +34,7 @@ struct PluginMetadataRegistryTypeCountTests { "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", "Trino", - "Turso", "Typesense" + "Turso", "Typesense", "Weaviate", "OceanBase" ] private static func builtInTypeIds() -> Set { @@ -43,10 +43,10 @@ struct PluginMetadataRegistryTypeCountTests { return Set(curated + registry) } - @Test("The app ships 34 database types before any plugin loads") + @Test("The app ships 36 database types before any plugin loads") func builtInDefaultsCoverTwentyNineTypes() { let ids = Self.builtInTypeIds() - #expect(ids.count == 34) + #expect(ids.count == 36) #expect(ids == Self.expectedTypeIds) } @@ -64,6 +64,7 @@ struct PluginMetadataRegistryTypeCountTests { "MariaDB": "MySQL", "TiDB": "MySQL", "Databend": "MySQL", + "OceanBase": "MySQL", "Redshift": "PostgreSQL", "CockroachDB": "PostgreSQL", "PGlite": "PostgreSQL", diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift index 38e92eee94..2314d8d2d4 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift @@ -104,6 +104,7 @@ struct PluginMetadataRegistryVariantTests { #expect(registry.snapshot(for: .cockroachdb)?.defaultPort == 26_257) #expect(registry.snapshot(for: .tidb)?.defaultPort == 4_000) #expect(registry.snapshot(for: .databend)?.defaultPort == 3_307) + #expect(registry.snapshot(for: .oceanbase)?.defaultPort == 2_881) } /// One connection, one dialect. The filter preview reads PluginManager.sqlDialect while the @@ -112,7 +113,7 @@ struct PluginMetadataRegistryVariantTests { @MainActor @Test("every reader of the editor dialect agrees for a variant type") func dialectReadersAgreeForAVariant() throws { - for databaseType in [DatabaseType.redshift, .cockroachdb, .pglite, .mariadb, .tidb, .databend] { + for databaseType in [DatabaseType.redshift, .cockroachdb, .pglite, .mariadb, .tidb, .databend, .oceanbase] { let viaManager = try #require(PluginManager.shared.sqlDialect(for: databaseType)) let viaHelper = try resolveSQLDialect(for: databaseType) #expect(viaManager.caseSensitivityStyle == viaHelper.caseSensitivityStyle) diff --git a/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift b/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift index f6e774a486..676094c2aa 100644 --- a/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift +++ b/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift @@ -21,6 +21,7 @@ struct SocketPathPlaceholderTests { func mysqlProtocolVariantsHaveNoSocket() { #expect(PluginManager.shared.defaultUnixSocketPath(for: .tidb) == nil) #expect(PluginManager.shared.defaultUnixSocketPath(for: .databend) == nil) + #expect(PluginManager.shared.defaultUnixSocketPath(for: .oceanbase) == nil) } @Test("PostgreSQL uses the PGSQL socket") diff --git a/TableProTests/Core/Plugins/StructureEditMatrixCurationTests.swift b/TableProTests/Core/Plugins/StructureEditMatrixCurationTests.swift new file mode 100644 index 0000000000..994b62658c --- /dev/null +++ b/TableProTests/Core/Plugins/StructureEditMatrixCurationTests.swift @@ -0,0 +1,66 @@ +// +// StructureEditMatrixCurationTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +/// The matrix is a curated per-engine capability with no `DriverPlugin` static behind it, so it has to +/// survive `buildMetadataSnapshot` the way every other curated capability does. A capability reset to +/// its struct default the moment a plugin loaded is what silently disabled MongoDB's database-scoped +/// authentication (#1970), and the same shape here would put the Structure tab's whole per-kind gate +/// back to tables only for every build with the PostgreSQL plugin installed. (#2726) +@Suite("Structure Edit Matrix Curation") +@MainActor +struct StructureEditMatrixCurationTests { + @Test("PostgreSQL is curated with the measured per-kind matrix") + func postgresIsCurated() { + let matrix = PluginManager.shared.structureEditMatrix(for: .postgresql) + #expect(StructureEditEligibility.allows(.addIndex, on: .materializedView, matrix: matrix)) + #expect(!StructureEditEligibility.allows(.setDefault, on: .materializedView, matrix: matrix)) + #expect(StructureEditEligibility.allows(.setDefault, on: .view, matrix: matrix)) + #expect(!StructureEditEligibility.allows(.addColumn, on: .view, matrix: matrix)) + } + + /// Conservative on purpose. An engine nobody has measured must never be offered an edit on + /// anything but a table, so the fallback is `.tablesOnly` rather than another engine's matrix. + @Test("An engine with no curated entry falls back to tables only") + func uncuratedEngineFallsBackToTablesOnly() { + let unknown = DatabaseType(rawValue: "NotARealEngine") + let matrix = PluginManager.shared.structureEditMatrix(for: unknown) + #expect(!StructureEditEligibility.allowsAnyEdit(on: .view, matrix: matrix)) + #expect(!StructureEditEligibility.allowsAnyEdit(on: .materializedView, matrix: matrix)) + #expect(StructureEditEligibility.allows(.addColumn, on: .table, matrix: matrix)) + } + + @Test("Every curated engine still offers a table its edits") + func curatedEnginesKeepTheirTables() { + for type in DatabaseType.allKnownTypes { + let matrix = PluginManager.shared.structureEditMatrix(for: type) + #expect( + StructureEditEligibility.allowsAnyEdit(on: .table, matrix: matrix), + "\(type.rawValue) withholds every edit on a plain table" + ) + } + } + + @Test("The snapshot's copying helpers carry the matrix across") + func copyingHelpersPreserveTheMatrix() throws { + let original = try #require(PluginMetadataRegistry.shared.snapshot(for: .postgresql)) + let expected = original.structureEditing.structureEdits + + let copies: [(String, PluginMetadataSnapshot)] = [ + ("withIconName", original.withIconName("other-icon")), + ("withExplainVariants", original.withExplainVariants([])), + ("withBranding", original.withBranding(from: original)), + ("withIsDownloadable", original.withIsDownloadable(!original.isDownloadable)), + ("withSwitchRouting", original.withSwitchRouting(from: original)) + ] + + for (name, copy) in copies { + #expect(copy.structureEditing.structureEdits == expected, "\(name) reset the per-kind matrix") + } + } +} diff --git a/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift b/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift new file mode 100644 index 0000000000..7994b5dbec --- /dev/null +++ b/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift @@ -0,0 +1,238 @@ +// +// SchemaOperationRefusalTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class RefusingDDLDriver: PluginDatabaseDriver, @unchecked Sendable { + var refuse: (PluginSchemaOperation) -> String? = { _ in nil } + + var currentSchema: String? { "public" } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { refuse(operation) } + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + "CREATE TABLE \(definition.tableName) (...)" + } + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + "ALTER TABLE \(table) ADD COLUMN \(column.name)" + } + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + "CREATE INDEX \(index.name) ON \(table)" + } + func generateAddCheckConstraintSQL(table: String, constraint: PluginCheckConstraintDefinition) -> String? { + "ALTER TABLE \(table) ADD CONSTRAINT \(constraint.name) CHECK (\(constraint.expression))" + } + func generateDropCheckConstraintSQL(table: String, constraintName: String) -> String? { + "ALTER TABLE \(table) DROP CONSTRAINT \(constraintName)" + } +} + +private enum RefusalReason { + static let generated = "Column total is a generated column, which needs PostgreSQL 12 or later." + static let brin = "BRIN indexes need PostgreSQL 9.5 or later." + static let rename = "Renaming a check constraint needs PostgreSQL 9.2 or later." +} + +@MainActor @Suite("Schema operation refusal") +struct SchemaOperationRefusalTests { + private static let generatedReason = RefusalReason.generated + private static let brinReason = RefusalReason.brin + private static let renameReason = RefusalReason.rename + + private func legacyDriver() -> RefusingDDLDriver { + let driver = RefusingDDLDriver() + driver.refuse = { operation in + switch operation { + case .addColumn(let column): + return column.isGenerated ? RefusalReason.generated : nil + case .addIndex(let index): + return index.indexType == "BRIN" ? RefusalReason.brin : nil + case .renameCheckConstraint: + return RefusalReason.rename + @unknown default: + return nil + } + } + return driver + } + + private func column(_ name: String, generated: Bool) -> EditableColumnDefinition { + var column = EditableColumnDefinition.placeholder() + column.name = name + column.dataType = "integer" + if generated { + column.generationKind = .stored + column.generationExpression = "qty * price" + } + return column + } + + private func index(_ name: String, type: EditableIndexDefinition.IndexType) -> EditableIndexDefinition { + var index = EditableIndexDefinition.placeholder() + index.name = name + index.columns = ["qty"] + index.type = type + return index + } + + private func constraint(_ name: String, _ expression: String) -> EditableCheckConstraintDefinition { + EditableCheckConstraintDefinition(id: UUID(), name: name, expression: expression, columns: [], isValidated: true) + } + + private func refusal(of change: SchemaChange, driver: RefusingDDLDriver) -> String? { + do { + _ = try SchemaStatementGenerator(tableName: "orders", pluginDriver: driver).generate(changes: [change]) + return nil + } catch let error as SchemaOperationRefusedError { + return error.reason + } catch { + return "unexpected: \(error.localizedDescription)" + } + } + + private func refusalOfBatch(_ changes: [SchemaChange], driver: RefusingDDLDriver) -> String? { + do { + _ = try SchemaStatementGenerator(tableName: "orders", pluginDriver: driver).generate(changes: changes) + return nil + } catch let error as SchemaOperationRefusedError { + return error.reason + } catch { + return "unexpected: \(error.localizedDescription)" + } + } + + @Test("A refused column is reported with the driver's reason, not a generic unsupported message") + func refusedColumnSurfacesReason() { + #expect(refusal(of: .addColumn(column("total", generated: true)), driver: legacyDriver()) == Self.generatedReason) + #expect(refusal(of: .addColumn(column("qty", generated: false)), driver: legacyDriver()) == nil) + } + + @Test("A refused index is reported whether it is added or modified") + func refusedIndexSurfacesReason() { + let driver = legacyDriver() + #expect(refusal(of: .addIndex(index("ix_brin", type: .brin)), driver: driver) == Self.brinReason) + let modify = SchemaChange.modifyIndex(old: index("ix", type: .btree), new: index("ix", type: .brin)) + #expect(refusal(of: modify, driver: driver) == Self.brinReason) + #expect(refusal(of: .addIndex(index("ix_btree", type: .btree)), driver: driver) == nil) + } + + @Test("A refusal is reported ahead of a change in the same save that the driver cannot generate") + func refusalWinsOverUngeneratableChange() { + let unsupportedDrop = SchemaChange.deleteIndex(index("ix_old", type: .btree)) + let refusedAdd = SchemaChange.addIndex(index("ix_brin", type: .brin)) + #expect(refusal(of: unsupportedDrop, driver: legacyDriver())?.hasPrefix("unexpected:") == true) + #expect(refusalOfBatch([unsupportedDrop, refusedAdd], driver: legacyDriver()) == Self.brinReason) + } + + @Test("A refused rename stays refused and never turns into a drop and re-add") + func refusedRenameIsNotDroppedAndReAdded() { + let rename = SchemaChange.modifyCheckConstraint(old: constraint("ck_a", "qty > 0"), new: constraint("ck_b", "qty > 0")) + #expect(refusal(of: rename, driver: legacyDriver()) == Self.renameReason) + } + + @Test("An expression change is a drop and re-add, which a rename refusal does not block") + func expressionChangeIsNotARename() throws { + let change = SchemaChange.modifyCheckConstraint(old: constraint("ck_a", "qty > 0"), new: constraint("ck_a", "qty > 1")) + let statements = try SchemaStatementGenerator(tableName: "orders", pluginDriver: legacyDriver()) + .generate(changes: [change]) + #expect(statements.map(\.sql) == [ + "ALTER TABLE orders DROP CONSTRAINT ck_a;", + "ALTER TABLE orders ADD CONSTRAINT ck_a CHECK (qty > 1);" + ]) + } + + @Test("A driver that never refuses generates as before") + func defaultDriverRefusesNothing() throws { + let statements = try SchemaStatementGenerator(tableName: "orders", pluginDriver: RefusingDDLDriver()) + .generate(changes: [.addColumn(column("total", generated: true))]) + #expect(statements.map(\.sql) == ["ALTER TABLE orders ADD COLUMN total;"]) + } + + @Test("Create Table names a refused column instead of saying the database cannot create tables") + func createTableNamesRefusedColumn() { + let plan = CreateTablePlan( + definition: PluginCreateTableDefinition( + tableName: "orders", + columns: [column("qty", generated: false).toPlugin(), column("total", generated: true).toPlugin()], + primaryKeyColumns: [] + ), + indexes: [], + issues: [] + ) + let composed = CreateTableStatementComposer.compose(plan: plan, driver: legacyDriver()) + #expect(composed.statements.isEmpty) + #expect(composed.issues.map(\.message) == [Self.generatedReason]) + #expect(composed.issues.first?.tab == .columns) + } + + @Test("Create Table flags a refused index on its own row and still creates the table") + func createTableFlagsRefusedIndexRow() { + let plan = CreateTablePlan( + definition: PluginCreateTableDefinition( + tableName: "orders", + columns: [column("qty", generated: false).toPlugin()], + primaryKeyColumns: [] + ), + indexes: [index("ix_btree", type: .btree).toPlugin(), index("ix_brin", type: .brin).toPlugin()], + issues: [] + ) + let composed = CreateTableStatementComposer.compose(plan: plan, driver: legacyDriver()) + #expect(composed.statements == ["CREATE TABLE orders (...)", "CREATE INDEX ix_btree ON orders"]) + #expect(composed.issues.count == 1) + #expect(composed.issues.first?.tab == .indexes) + #expect(composed.issues.first?.row == 1) + #expect(composed.issues.first?.message == Self.brinReason) + } + + @Test("Schema sync refuses to create a table the target cannot hold, naming table and reason") + func schemaSyncRefusesCreateTable() { + let snapshot = TableStructureSnapshot(name: "orders", columns: [column("total", generated: true)]) + let builder = SchemaSyncScriptBuilder(targetDriver: legacyDriver()) + do { + _ = try builder.build(operations: [.createTable(snapshot)], foreignKeysByTable: [:]) + Issue.record("expected a refusal") + } catch { + #expect(error.localizedDescription.contains("orders")) + #expect(error.localizedDescription.contains(Self.generatedReason)) + } + } + + @Test("Schema sync refuses to add a column the target cannot hold") + func schemaSyncRefusesAlterTable() { + let builder = SchemaSyncScriptBuilder(targetDriver: legacyDriver()) + do { + _ = try builder.build( + operations: [.alterTable(name: "orders", schema: nil, changes: [.addColumn(column("total", generated: true))])], + foreignKeysByTable: [:] + ) + Issue.record("expected a refusal") + } catch { + #expect(error.localizedDescription == Self.generatedReason) + } + } +} diff --git a/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift b/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift new file mode 100644 index 0000000000..11f3799e87 --- /dev/null +++ b/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift @@ -0,0 +1,144 @@ +// +// PostgreSQLDashboardProviderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("PostgreSQL server dashboard across server versions") +struct PostgreSQLDashboardProviderTests { + private struct QueryFailure: Error {} + + private func catalog(for databaseType: DatabaseType, serverVersion: String?) throws -> PostgreSQLActivityCatalog { + let provider = ServerDashboardQueryProviderFactory.provider(for: databaseType, serverVersion: serverVersion) + let postgres = try #require(provider as? PostgreSQLDashboardProvider) + return postgres.activityCatalog + } + + private func result(columns: [String], rows: [[String?]]) -> QueryResult { + QueryResult( + columns: columns, + columnTypes: [], + rows: rows.map { row in row.map { $0.map { PluginCellValue.text($0) } ?? .null } }, + rowsAffected: 0, + executionTime: 0, + error: nil + ) + } + + @Test( + "PostgreSQL picks the pg_stat_activity shape its server version has", + arguments: [ + ("9.1.24", PostgreSQLActivityCatalog.procpid), + ("9.2.23", .withoutBackendType), + ("9.6.24", .withoutBackendType), + ("10.21", .current), + ("17.11", .current) + ] + ) + func postgresCatalogFollowsVersion(serverVersion: String, expected: PostgreSQLActivityCatalog) throws { + #expect(try catalog(for: .postgresql, serverVersion: serverVersion) == expected) + } + + @Test("An unknown PostgreSQL version keeps the current catalog") + func unknownVersionKeepsCurrent() throws { + #expect(try catalog(for: .postgresql, serverVersion: nil) == .current) + #expect(try catalog(for: .postgresql, serverVersion: "unparsable") == .current) + } + + @Test("Redshift and CockroachDB keep the current catalog whatever version they report") + func forksKeepCurrent() throws { + #expect(try catalog(for: .redshift, serverVersion: "8.0.2") == .current) + #expect(try catalog(for: .cockroachdb, serverVersion: "13.0.0") == .current) + } + + @Test("Servers before 10 never ask for backend_type") + func legacyCatalogsSkipBackendType() { + for catalog in [PostgreSQLActivityCatalog.withoutBackendType, .procpid] { + let queries = [ + catalog.sessionsQuery, catalog.connectionCountQuery, + catalog.activeQueryCountQuery, catalog.slowQueriesQuery + ] + #expect(queries.allSatisfy { !$0.contains("backend_type") }) + } + } + + @Test("Servers before 9.2 read procpid and current_query, never pid, state or query") + func procpidCatalogUsesPreNinePointTwoColumns() { + let catalog = PostgreSQLActivityCatalog.procpid + let queries = [catalog.sessionsQuery, catalog.activeQueryCountQuery, catalog.slowQueriesQuery] + #expect(queries.allSatisfy { $0.contains("procpid <> pg_backend_pid()") && $0.contains("current_query") }) + #expect(queries.allSatisfy { !$0.contains("left(query") }) + #expect(catalog.sessionsQuery.contains("SELECT procpid AS pid")) + #expect(catalog.slowQueriesQuery.contains("SELECT procpid AS pid")) + #expect(!catalog.slowQueriesQuery.contains("state =")) + #expect(!catalog.activeQueryCountQuery.contains("state =")) + } + + @Test("The current catalog is the SQL the dashboard has always sent") + func currentCatalogIsUnchanged() { + let catalog = PostgreSQLActivityCatalog.current + #expect(catalog.connectionCountQuery == "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'") + #expect(catalog.sessionsQuery.contains("AND backend_type = 'client backend'")) + #expect(catalog.slowQueriesQuery.contains("WHERE state = 'active'")) + } + + @Test("CockroachDB is asked only for the metrics it has") + func cockroachMetricSet() throws { + let provider = ServerDashboardQueryProviderFactory.provider(for: .cockroachdb, serverVersion: "13.0.0") + let postgres = try #require(provider as? PostgreSQLDashboardProvider) + #expect(postgres.metricSet == .activityOnly) + #expect(PostgreSQLDashboardMetricSet(databaseType: .postgresql) == .full) + #expect(PostgreSQLDashboardMetricSet(databaseType: .redshift) == .full) + } + + @Test("The CockroachDB metric set runs two statements, not five") + func cockroachRunsTwoStatements() async throws { + let provider = PostgreSQLDashboardProvider(activityCatalog: .current, metricSet: .activityOnly) + var asked: [String] = [] + let metrics = try await provider.fetchMetrics { sql in + asked.append(sql) + return result(columns: ["value"], rows: [["2"]]) + } + #expect(asked.count == 2) + #expect(metrics.map(\.id) == ["connections", "active_queries"]) + #expect(!asked.contains { $0.contains("pg_size_pretty") || $0.contains("pg_postmaster_start_time") }) + } + + @Test("One failing metric leaves the others on the panel") + func metricFailureIsIsolated() async throws { + let provider = PostgreSQLDashboardProvider(activityCatalog: .current) + let metrics = try await provider.fetchMetrics { sql in + if sql.contains("backend_type") { throw QueryFailure() } + return result(columns: ["value"], rows: [["7"]]) + } + #expect(metrics.map(\.id) == ["cache_hit", "db_size", "uptime", "active_queries"]) + } + + @Test("The metrics panel fails only when every metric fails") + func metricsFailWhenAllFail() async { + let provider = PostgreSQLDashboardProvider(activityCatalog: .current) + await #expect(throws: QueryFailure.self) { + _ = try await provider.fetchMetrics { _ in throw QueryFailure() } + } + } + + @Test("Sessions map pid, state and query from the procpid catalog's aliases") + func procpidSessionsMap() async throws { + let provider = PostgreSQLDashboardProvider(activityCatalog: .procpid) + let sessions = try await provider.fetchSessions { _ in + result( + columns: ["pid", "usename", "datname", "state", "duration_secs", "query"], + rows: [["1733", "postgres", "app", "active", "3", "SELECT pg_sleep(12)"]] + ) + } + let session = try #require(sessions.first) + #expect(session.id == "1733") + #expect(session.state == "active") + #expect(session.durationSeconds == 3) + #expect(session.query == "SELECT pg_sleep(12)") + } +} diff --git a/TableProTests/Core/Services/Execution/ExecutionGateTests.swift b/TableProTests/Core/Services/Execution/ExecutionGateTests.swift index a459402735..050ed9e3da 100644 --- a/TableProTests/Core/Services/Execution/ExecutionGateTests.swift +++ b/TableProTests/Core/Services/Execution/ExecutionGateTests.swift @@ -11,15 +11,17 @@ import Testing final class StubConfirming: OperationConfirming, @unchecked Sendable { private(set) var callCount = 0 private(set) var lastDestructive = false + private(set) var lastRequest: OperationConfirmationRequest? private let answer: Bool init(answer: Bool) { self.answer = answer } - func confirm(sql: String, operationDescription: String, connectionId: UUID, isDestructive: Bool) async -> Bool { + func confirm(_ request: OperationConfirmationRequest) async -> Bool { callCount += 1 - lastDestructive = isDestructive + lastDestructive = request.isDestructive + lastRequest = request return answer } } diff --git a/TableProTests/Core/Services/Execution/OperationConfirmationPromptTests.swift b/TableProTests/Core/Services/Execution/OperationConfirmationPromptTests.swift new file mode 100644 index 0000000000..54ac22f2c3 --- /dev/null +++ b/TableProTests/Core/Services/Execution/OperationConfirmationPromptTests.swift @@ -0,0 +1,186 @@ +// +// OperationConfirmationPromptTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import Testing +import TableProPluginKit + +@Suite("Operation confirmation prompt") +@MainActor +struct OperationConfirmationPromptTests { + private static let escape = "\u{1B}" + private static let returnKey = "\r" + + private func makeRequest( + sql: String?, + caller: OperationCaller = .userInterface, + connectionName: String? = "Production", + isDestructive: Bool = false, + operationDescription: String = "Execute Query" + ) -> OperationConfirmationRequest { + OperationConfirmationRequest( + sql: sql, + operationDescription: operationDescription, + connectionId: UUID(), + connectionName: connectionName, + databaseType: .mysql, + caller: caller, + isDestructive: isDestructive + ) + } + + // MARK: - The statement + + /// The whole point of #2759: the statement the user is approving reaches the dialog intact. + @Test("A statement past the old 200 character cut is carried whole") + func longStatementIsNotTruncated() { + let statement = (1 ... 40) + .map { "UPDATE accounts SET balance = balance - \($0) WHERE customer_id = \($0);" } + .joined(separator: "\n") + #expect((statement as NSString).length > 200) + + let carried = OperationConfirmationPrompt.statement(of: makeRequest(sql: statement)) + #expect(carried == statement) + } + + @Test("Surrounding whitespace is trimmed but the statement's own line breaks survive") + func statementKeepsItsLineBreaks() { + let carried = OperationConfirmationPrompt.statement(of: makeRequest(sql: "\n\nSELECT\n 1\n\n")) + #expect(carried == "SELECT\n 1") + } + + @Test("A request with no statement has nothing to show") + func missingStatementIsAbsent() { + #expect(OperationConfirmationPrompt.statement(of: makeRequest(sql: nil)) == nil) + #expect(OperationConfirmationPrompt.statement(of: makeRequest(sql: "")) == nil) + #expect(OperationConfirmationPrompt.statement(of: makeRequest(sql: " \n ")) == nil) + } + + // MARK: - Who is asking, and where + + @Test("A named MCP client is named, with the connection it is reaching") + func mcpClientIsNamed() { + let subtitle = OperationConfirmationPrompt.subtitle( + of: makeRequest(sql: "SELECT 1", caller: .mcpClient(label: "Claude")) + ) + #expect(subtitle.contains("Claude")) + #expect(subtitle.contains("Production")) + } + + @Test("An unnamed MCP client still says a client is asking") + func anonymousMcpClientIsDescribed() { + let subtitle = OperationConfirmationPrompt.subtitle( + of: makeRequest(sql: "SELECT 1", caller: .mcpClient(label: nil)) + ) + #expect(subtitle.contains("Production")) + #expect(!subtitle.isEmpty) + } + + @Test("Every remote caller is distinguishable from the app itself") + func callersAreDistinguishable() { + let subtitles = [ + OperationCaller.mcpClient(label: "Claude"), + .appleScript(client: "Raycast"), + .aiAssistant(sessionId: nil), + .userInterface + ].map { caller in + OperationConfirmationPrompt.subtitle(of: makeRequest(sql: "SELECT 1", caller: caller)) + } + #expect(Set(subtitles).count == subtitles.count) + } + + @Test("An unknown connection name is left out rather than shown empty") + func unknownConnectionIsOmitted() { + for name in [nil, "", " "] as [String?] { + let subtitle = OperationConfirmationPrompt.subtitle( + of: makeRequest(sql: "SELECT 1", caller: .mcpClient(label: "Claude"), connectionName: name) + ) + #expect(!subtitle.contains("''")) + #expect(!subtitle.isEmpty) + } + } + + // MARK: - Destructive + + @Test("Only a destructive operation carries the warning") + func destructiveWarningIsConditional() { + #expect(OperationConfirmationPrompt.destructiveWarning(of: makeRequest(sql: "SELECT 1")) == nil) + #expect( + OperationConfirmationPrompt.destructiveWarning( + of: makeRequest(sql: "DROP TABLE users", isDestructive: true) + ) != nil + ) + } + + // MARK: - The no-statement alert + + /// A rename has no statement, so it stays an alert rather than rendering an empty review box. + @Test("The no-statement alert names the operation and never trails a blank line") + func statementlessAlertReadsCleanly() { + let alert = OperationConfirmationPrompt.makeAlert( + for: makeRequest(sql: nil, operationDescription: "Rename users to customers") + ) + #expect(alert.messageText == "Rename users to customers") + #expect(!alert.informativeText.isEmpty) + #expect(!alert.informativeText.hasSuffix("\n")) + #expect(alert.informativeText.contains("Production")) + } + + @Test("The no-statement alert keeps Escape on cancel and takes Return off execute") + func statementlessAlertKeyBindings() { + let alert = OperationConfirmationPrompt.makeAlert(for: makeRequest(sql: nil)) + #expect(alert.buttons.count == 2) + #expect(alert.buttons[0].hasDestructiveAction) + #expect(alert.buttons[0].keyEquivalent != Self.returnKey) + #expect(alert.buttons[1].keyEquivalent == Self.escape) + #expect(alert.buttons.filter { $0.keyEquivalent == Self.returnKey }.isEmpty) + } + + @Test("A destructive operation with no statement is presented as critical") + func statementlessDestructiveAlertIsCritical() { + let alert = OperationConfirmationPrompt.makeAlert( + for: makeRequest(sql: nil, isDestructive: true) + ) + #expect(alert.alertStyle == .critical) + } + + // MARK: - The review dialog's confirming button + + /// The alert this dialog replaced took Return off its confirming button on purpose + /// (`AlertHelper.addConfirmAndCancel`). A confirmation raised by an MCP client activates the app + /// over whatever the user was typing in, so a Return already on its way would answer it. + @Test("A confirmation raised for someone else never gives Execute the Return key") + func confirmationExecuteIsNotTheDefaultButton() { + for isDestructive in [true, false] { + let action = SQLReviewSheet.PrimaryAction( + title: OperationConfirmationPrompt.confirmTitle, + isDestructive: isDestructive, + takesDefaultAction: false + ) {} + #expect(!action.takesDefaultAction) + } + } + + /// The Users and Roles review is a step the user asked for, so its Execute keeps Return. + @Test("A review the user opened keeps Return on its confirming button by default") + func userInitiatedReviewKeepsTheDefaultButton() { + let action = SQLReviewSheet.PrimaryAction(title: "Execute", isDestructive: false) {} + #expect(action.takesDefaultAction) + } + + // MARK: - MCP titles + + @Test("An MCP operation label becomes the dialog's title") + func mcpOperationLabelTitlesTheDialog() { + let titled = MCPAuthPolicy.operationDescription(for: "transaction begin") + #expect(titled.contains("transaction begin")) + + let fallback = MCPAuthPolicy.operationDescription(for: nil) + #expect(!fallback.isEmpty) + #expect(MCPAuthPolicy.operationDescription(for: "") == fallback) + } +} diff --git a/TableProTests/Core/Services/Highlight/HighlightConditionTests.swift b/TableProTests/Core/Services/Highlight/HighlightConditionTests.swift new file mode 100644 index 0000000000..c18f9d2f9f --- /dev/null +++ b/TableProTests/Core/Services/Highlight/HighlightConditionTests.swift @@ -0,0 +1,147 @@ +// +// HighlightConditionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Highlight condition matching") +struct HighlightConditionTests { + private func matches( + _ value: PluginCellValue, + _ filterOperator: FilterOperator, + _ operand: String = "", + second: String? = nil, + caseSensitive: Bool? = nil, + type: ColumnType? = .text(rawType: "VARCHAR") + ) -> Bool { + let rule = HighlightRule( + columnName: "c", + filterOperator: filterOperator, + value: operand, + secondValue: second, + isCaseSensitive: caseSensitive + ) + return HighlightCondition(rule: rule, columnType: type).matches(value) + } + + @Test("Equality on text is exact and case-sensitive by default") + func textEquality() { + #expect(matches("paid", .equal, "paid")) + #expect(!matches("Paid", .equal, "paid")) + #expect(matches("Paid", .equal, "paid", caseSensitive: false)) + #expect(matches("pending", .notEqual, "paid")) + #expect(!matches("007", .equal, "7")) + } + + @Test("A padded value matches exactly as stored, so a quick rule matches its own cell") + func paddedValuesMatchAsStored() { + #expect(matches("abc ", .equal, "abc ", type: .text(rawType: "CHAR(10)"))) + #expect(!matches("abc", .equal, "abc ", type: .text(rawType: "CHAR(10)"))) + #expect(matches(" ", .equal, " ")) + #expect(matches("42", .equal, " 42 ", type: .integer(rawType: "INT"))) + } + + @Test("NULL fails every comparison and matches only IS NULL and IS EMPTY") + func nullSemantics() { + #expect(matches(.null, .isNull)) + #expect(matches(.null, .isEmpty)) + #expect(!matches(.null, .isNotNull)) + #expect(!matches(.null, .notEqual, "paid")) + #expect(!matches(.null, .greaterThan, "1", type: .integer(rawType: "INT"))) + #expect(!matches(.null, .notContains, "x")) + #expect(!matches(.null, .notInList, "a, b")) + } + + @Test("The literal NULL means IS NULL on a column that is not text") + func nullLiteral() { + #expect(matches(.null, .equal, "NULL", type: .integer(rawType: "INT"))) + #expect(!matches("5", .equal, "NULL", type: .integer(rawType: "INT"))) + #expect(matches("5", .notEqual, "null", type: .integer(rawType: "INT"))) + #expect(!matches(.null, .equal, "NULL")) + #expect(matches("NULL", .equal, "NULL")) + } + + @Test("Numbers compare numerically on a numeric column") + func numericColumns() { + let integer = ColumnType.integer(rawType: "INT") + #expect(matches("1000", .greaterThan, "999", type: integer)) + #expect(matches("1.0", .equal, "1", type: .decimal(rawType: "DECIMAL"))) + #expect(matches("5", .between, "1", second: "10", type: integer)) + #expect(!matches("11", .between, "1", second: "10", type: integer)) + #expect(matches("10", .lessOrEqual, "10", type: integer)) + } + + @Test("Ordering on text compares numerically only when both sides are numbers") + func orderingOnText() { + #expect(matches("1000", .greaterThan, "999")) + #expect(matches("banana", .greaterThan, "apple")) + #expect(!matches("apple", .greaterThan, "banana")) + } + + @Test("Boolean columns accept every spelling of true and false") + func booleans() { + let boolean = ColumnType.boolean(rawType: "BOOLEAN") + #expect(matches("t", .equal, "true", type: boolean)) + #expect(matches("1", .equal, "yes", type: boolean)) + #expect(matches("false", .equal, "0", type: boolean)) + #expect(!matches("f", .equal, "true", type: boolean)) + #expect(matches("1", .equal, "true", type: .integer(rawType: "TINYINT(1)"))) + } + + @Test("Pattern operators ignore case by default and honour Match Case") + func patterns() { + #expect(matches("Hello World", .contains, "world")) + #expect(!matches("Hello World", .contains, "world", caseSensitive: true)) + #expect(matches("Hello", .startsWith, "he")) + #expect(matches("Hello", .endsWith, "LLO")) + #expect(!matches("Hello", .endsWith, "hel")) + #expect(matches("Hello", .notContains, "xyz")) + } + + @Test("Empty means NULL or an empty string on text, and only NULL elsewhere") + func emptiness() { + #expect(matches("", .isEmpty)) + #expect(!matches("x", .isEmpty)) + #expect(matches("x", .isNotEmpty)) + #expect(!matches("", .isNotEmpty)) + #expect(!matches("", .isEmpty, type: .integer(rawType: "INT"))) + #expect(matches("", .isNotEmpty, type: .integer(rawType: "INT"))) + } + + @Test("IN and NOT IN split on commas and trim each item") + func lists() { + #expect(matches("b", .inList, "a, b ,c")) + #expect(!matches("d", .inList, "a, b, c")) + #expect(matches("d", .notInList, "a, b, c")) + #expect(!matches("a", .notInList, "a, b")) + #expect(matches(.null, .inList, "a, NULL", type: .integer(rawType: "INT"))) + } + + @Test("A regular expression searches the value, and an invalid one matches nothing") + func regex() { + #expect(matches("order-42", .regex, "\\d+$")) + #expect(!matches("order", .regex, "\\d+$")) + #expect(matches("ABC", .regex, "abc", caseSensitive: false)) + #expect(!matches("anything", .regex, "(unclosed")) + } + + @Test("A binary value matches only IS NULL and IS NOT NULL") + func binary() { + let bytes = PluginCellValue.bytes(Data([0x01, 0x02])) + #expect(matches(bytes, .isNotNull)) + #expect(!matches(bytes, .isNull)) + #expect(!matches(bytes, .equal, "0x0102")) + #expect(!matches(bytes, .contains, "01")) + } + + @Test("A search past the cap only looks at the leading part of a very long value") + func searchIsCapped() { + let long = String(repeating: "a", count: HighlightCondition.searchLimit + 50) + "needle" + #expect(!matches(.text(long), .contains, "needle")) + #expect(matches(.text("needle" + long), .contains, "needle")) + } +} diff --git a/TableProTests/Core/Services/Highlight/HighlightRuleSetTests.swift b/TableProTests/Core/Services/Highlight/HighlightRuleSetTests.swift new file mode 100644 index 0000000000..b8f5d9713e --- /dev/null +++ b/TableProTests/Core/Services/Highlight/HighlightRuleSetTests.swift @@ -0,0 +1,176 @@ +// +// HighlightRuleSetTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Highlight rule set") +struct HighlightRuleSetTests { + private let columns = ["id", "status", "total"] + private let types: [ColumnType] = [.integer(rawType: "INT"), .text(rawType: "VARCHAR"), .decimal(rawType: "DECIMAL")] + + private func row(_ values: PluginCellValue...) -> ContiguousArray { + ContiguousArray(values) + } + + @Test("The first matching row rule sets the row's color, and reordering flips it") + func firstRowRuleWins() { + let paid = HighlightRule(columnName: "status", value: "paid", color: .green) + let big = HighlightRule(columnName: "total", filterOperator: .greaterThan, value: "100", color: .red) + let values = row("1", "paid", "500") + + let paidFirst = HighlightRuleSet(rules: [paid, big], columns: columns, columnTypes: types) + let bigFirst = HighlightRuleSet(rules: [big, paid], columns: columns, columnTypes: types) + + #expect(paidFirst.highlight(for: values).rowColor == .green) + #expect(bigFirst.highlight(for: values).rowColor == .red) + } + + @Test("A cell rule colours its own column and leaves the row rule in place") + func cellRulesColourTheirColumn() { + let rowRule = HighlightRule(columnName: "status", value: "paid", color: .green) + let cellRule = HighlightRule( + columnName: "total", filterOperator: .greaterThan, value: "100", color: .red, target: .cell + ) + let highlight = HighlightRuleSet(rules: [rowRule, cellRule], columns: columns, columnTypes: types) + .highlight(for: row("1", "paid", "500")) + + #expect(highlight.rowColor == .green) + #expect(highlight.cellRule(forColumn: 2)?.color == .red) + #expect(highlight.cellRule(forColumn: 1) == nil) + #expect(highlight.describingRule(forColumn: 2) == cellRule) + #expect(highlight.describingRule(forColumn: 0) == rowRule) + } + + @Test("Disabled and incomplete rules never match") + func disabledAndIncompleteRules() { + let disabled = HighlightRule(isEnabled: false, columnName: "status", value: "paid", color: .green) + let incomplete = HighlightRule(columnName: "status", value: "", color: .red) + let set = HighlightRuleSet(rules: [disabled, incomplete], columns: columns, columnTypes: types) + + #expect(set.isEmpty) + #expect(set.highlight(for: row("1", "paid", "5")) == .none) + } + + @Test("A rule whose column is not in the result is reported, not dropped") + func missingColumnIsUnresolved() { + let rule = HighlightRule(columnName: "archived", filterOperator: .isNotNull, color: .gray) + let set = HighlightRuleSet(rules: [rule], columns: columns, columnTypes: types) + + #expect(set.unresolvedRuleIDs == [rule.id]) + #expect(set.highlight(for: row("1", "paid", "5")) == .none) + } + + @Test("A duplicated column name resolves by occurrence") + func duplicateColumnsResolveByOccurrence() { + let duplicated = ["status", "status"] + let textTypes: [ColumnType] = [.text(rawType: nil), .text(rawType: nil)] + let second = HighlightRule( + columnName: "status", columnOccurrence: 1, value: "paid", color: .blue, target: .cell + ) + let highlight = HighlightRuleSet(rules: [second], columns: duplicated, columnTypes: textTypes) + .highlight(for: row("paid", "paid")) + + #expect(highlight.cellRule(forColumn: 0) == nil) + #expect(highlight.cellRule(forColumn: 1)?.color == .blue) + #expect(HighlightRuleSet.occurrence(ofColumnAt: 1, in: duplicated) == 1) + #expect(HighlightRuleSet.columnIndex(named: "status", occurrence: 2, in: duplicated) == nil) + } +} + +@Suite("Highlight rule descriptions and quick rules") +@MainActor +struct HighlightRuleDescriptionTests { + @Test("A comparison reads as column, symbol and quoted value") + func comparisonTitle() { + let rule = HighlightRule(columnName: "status", value: "paid") + #expect(HighlightRuleDescription.condition(of: rule) == "status = “paid”") + } + + @Test("An operator without a value reads as its name") + func valuelessTitle() { + let rule = HighlightRule(columnName: "notes", filterOperator: .isNull) + #expect(HighlightRuleDescription.condition(of: rule) == "notes is NULL") + } + + @Test("A long value is truncated in menu titles only") + func longValuesTruncate() { + let value = String(repeating: "x", count: 50) + let rule = HighlightRule(columnName: "notes", value: value) + let title = HighlightRuleDescription.condition(of: rule, valueLimit: HighlightRuleDescription.menuValueLimit) + + #expect(title == "notes = “\(String(repeating: "x", count: 32))…”") + #expect(HighlightRuleDescription.condition(of: rule).contains(value)) + } + + @Test("The quick rule follows the clicked cell's raw value") + func quickRuleFromCell() { + let text = HighlightMenuBuilder.quickRule( + columnName: "status", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: "paid", target: .row, color: .green + ) + let null = HighlightMenuBuilder.quickRule( + columnName: "status", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: .null, target: .cell, color: .red + ) + let empty = HighlightMenuBuilder.quickRule( + columnName: "status", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: "", target: .row, color: .red + ) + let binary = HighlightMenuBuilder.quickRule( + columnName: "blob", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: .bytes(Data([1])), target: .row, color: .red + ) + + #expect(text?.filterOperator == .equal) + #expect(text?.value == "paid") + #expect(null?.filterOperator == .isNull) + #expect(null?.target == .cell) + #expect(empty?.filterOperator == .isEmpty) + #expect(binary == nil) + } + + @Test("A quick rule cannot be built from a value the rule would read as NULL") + func quickRuleRefusesTheNullKeyword() { + let json = HighlightMenuBuilder.quickRule( + columnName: "payload", columnOccurrence: 0, columnType: .json(rawType: "JSONB"), + value: "null", target: .row, color: .red + ) + let text = HighlightMenuBuilder.quickRule( + columnName: "note", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), + value: "null", target: .row, color: .red + ) + + #expect(json == nil) + #expect(text?.value == "null") + } + + @Test("Menu sections name the target and the condition") + func sectionTitles() { + let row = HighlightRule(columnName: "status", value: "paid", target: .row) + let cell = HighlightRule(columnName: "status", value: "paid", target: .cell) + + #expect(HighlightMenuBuilder.sectionTitle(for: row) == "Rows Where status = “paid”") + #expect(HighlightMenuBuilder.sectionTitle(for: cell) == "Cells Where status = “paid”") + } + + @Test("Two rules share a condition regardless of their color") + func sameCondition() { + let green = HighlightRule(columnName: "status", value: "paid", color: .green) + var red = green + red.color = .red + let cell = HighlightRule(columnName: "status", value: "paid", color: .green, target: .cell) + + #expect(HighlightRule(columnName: "status", value: "paid", color: .red).hasSameCondition(as: green)) + #expect(red.hasSameCondition(as: green)) + #expect(!cell.hasSameCondition(as: green)) + } + + @Test("Duplicated columns get a numbered label") + func columnOptions() { + let options = HighlightColumnOption.options(for: ["id", "status", "status"]) + + #expect(options.map(\.label) == ["id", "status (1)", "status (2)"]) + #expect(options.map(\.occurrence) == [0, 0, 1]) + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift index 61f26be14d..317749d3a2 100644 --- a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift +++ b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift @@ -262,6 +262,86 @@ struct DockerComposeExtractorTests { #expect(databend?.parsedURL.database == "default") } + @Test("OceanBase images map to OceanBase on 2881 as root@sys") + func testOceanBaseImageAndCredentials() { + let oceanbase = extract(""" + services: + ob: + image: oceanbase/oceanbase-ce:latest + ports: + - "2881:2881" + """).first + #expect(oceanbase?.parsedURL.type == .oceanbase) + #expect(oceanbase?.parsedURL.port == 2_881) + #expect(oceanbase?.parsedURL.username == "root@sys") + #expect(oceanbase?.parsedURL.password.isEmpty == true) + #expect(oceanbase?.parsedURL.database.isEmpty == true) + } + + @Test("An OceanBase tenant password names that tenant, and OBProxy is imported on its own port") + func testOceanBaseTenantAndProxy() { + let candidates = extract(""" + services: + ob: + image: oceanbase/oceanbase-ce:4.4.2 + environment: + OB_TENANT_NAME: app + OB_TENANT_PASSWORD: tenantpw + OB_SYS_PASSWORD: syspw + OB_DATABASE: shop + ports: + - "2881:2881" + proxy: + image: oceanbase/obproxy-ce:latest + ports: + - "2883:2883" + """) + let observer = candidates.first { $0.sourceKey == "services.ob" } + #expect(observer?.parsedURL.username == "root@app") + #expect(observer?.parsedURL.password == "tenantpw") + #expect(observer?.parsedURL.database == "shop") + let proxy = candidates.first { $0.sourceKey == "services.proxy" } + #expect(proxy?.parsedURL.type == .oceanbase) + #expect(proxy?.parsedURL.port == 2_883) + } + + @Test("An OBProxy cluster name joins the username, and an empty tenant name keeps the default") + func testOceanBaseClusterAndEmptyTenant() { + let candidates = extract(""" + services: + proxy: + image: oceanbase/obproxy-ce:latest + environment: + OB_CLUSTER_NAME: obcluster + ports: + - "2883:2883" + ob: + image: oceanbase/oceanbase-ce:latest + environment: + OB_TENANT_NAME: "" + ports: + - "2881:2881" + """) + #expect(candidates.first { $0.sourceKey == "services.proxy" }?.parsedURL.username == "root@sys#obcluster") + #expect(candidates.first { $0.sourceKey == "services.ob" }?.parsedURL.username == "root@sys") + } + + @Test("OceanBase images that do not serve SQL are not imported") + func testOceanBaseNonDatabaseImages() { + let candidates = extract(""" + services: + ocp: + image: oceanbase/ocp-ce:latest + ports: + - "8080:8080" + agent: + image: oceanbase/obagent:latest + miniob: + image: oceanbase/miniob:latest + """) + #expect(candidates.isEmpty) + } + @Test("Interpolation uses the adjacent dotenv file") func testInterpolationFromDotenv() { let contents = """ diff --git a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift index 50a80029fa..ca6a434c27 100644 --- a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift +++ b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift @@ -162,7 +162,7 @@ struct MetadataConnectionPoolIdleEvictionTests { func sweepClosesIdleEntries() { let connectionId = UUID() let driver = MockDatabaseDriver() - let pool = MetadataConnectionPool.shared + let pool = MetadataConnectionPool.isolatedForTesting() defer { pool.closeAll(connectionId: connectionId) } pool.injectEntry(driver, scope: scope(connectionId, database: "shop")) @@ -176,7 +176,7 @@ struct MetadataConnectionPoolIdleEvictionTests { func sweepSparesRecentEntries() { let connectionId = UUID() let driver = MockDatabaseDriver() - let pool = MetadataConnectionPool.shared + let pool = MetadataConnectionPool.isolatedForTesting() defer { pool.closeAll(connectionId: connectionId) } pool.injectEntry(driver, scope: scope(connectionId, database: "shop")) @@ -190,7 +190,7 @@ struct MetadataConnectionPoolIdleEvictionTests { func sweepSparesEntriesWithWorkInFlight() { let connectionId = UUID() let driver = MockDatabaseDriver() - let pool = MetadataConnectionPool.shared + let pool = MetadataConnectionPool.isolatedForTesting() defer { pool.closeAll(connectionId: connectionId) } pool.injectEntry(driver, scope: scope(connectionId, database: "shop")) @@ -206,7 +206,7 @@ struct MetadataConnectionPoolIdleEvictionTests { let connectionId = UUID() let stale = MockDatabaseDriver() let fresh = MockDatabaseDriver() - let pool = MetadataConnectionPool.shared + let pool = MetadataConnectionPool.isolatedForTesting() defer { pool.closeAll(connectionId: connectionId) } let now = Date() @@ -227,12 +227,16 @@ struct MetadataConnectionPoolIdleEvictionTests { @Test("a sweep that empties the pool stops the sweeper") func sweepStopsWhenThePoolEmpties() { let connectionId = UUID() - let pool = MetadataConnectionPool.shared + let pool = MetadataConnectionPool.isolatedForTesting() defer { pool.closeAll(connectionId: connectionId) } pool.injectEntry(MockDatabaseDriver(), scope: scope(connectionId, database: "shop")) + pool.startSweeperForTesting() + #expect(pool.hasSweeper) + pool.sweepIdleEntries(now: Date().addingTimeInterval(MetadataConnectionPool.idleTimeout + 1)) + #expect(pool.pooledDriverCount(for: connectionId) == 0) #expect(!pool.hasSweeper) } diff --git a/TableProTests/Core/Services/Query/PostgreSQLPlanParserTests.swift b/TableProTests/Core/Services/Query/PostgreSQLPlanParserTests.swift index 2a845abd1a..15b68f13fa 100644 --- a/TableProTests/Core/Services/Query/PostgreSQLPlanParserTests.swift +++ b/TableProTests/Core/Services/Query/PostgreSQLPlanParserTests.swift @@ -123,6 +123,34 @@ struct PostgreSQLPlanParserTests { #expect(plan.rootNode.actualLoops == 1) } + @Test("PostgreSQL 9.3 and earlier report the run time as Total Runtime") + func readsTotalRuntimeFromOlderServers() throws { + let legacyPlan = """ + [ + { + "Plan": { + "Node Type": "Result", + "Startup Cost": 0.00, + "Total Cost": 0.01, + "Plan Rows": 1, + "Plan Width": 0, + "Actual Startup Time": 0.191, + "Actual Total Time": 0.193, + "Actual Rows": 1, + "Actual Loops": 1 + }, + "Triggers": [], + "Total Runtime": 1.464 + } + ] + """ + + let plan = try #require(parser.parse(rawText: legacyPlan)) + + #expect(plan.executionTime == 1.464) + #expect(plan.planningTime == nil) + } + @Test("Rejects malformed and non-plan input") func rejectsMalformedInput() { #expect(parser.parse(rawText: "") == nil) diff --git a/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift index 0e6b0b064f..0d9a07314e 100644 --- a/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift +++ b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift @@ -1,8 +1,9 @@ import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @Suite("TabSessionRegistry+TableRows") @MainActor struct TabSessionRegistryTableRowsTests { @@ -129,7 +130,7 @@ struct TabSessionRegistryTableRowsTests { ) store.updateTableRows(for: tabId) { rows in - _ = rows.edit(row: 0, column: 0, value: "z") + rows.edit(row: 0, column: 0, value: "z") } let resolved = store.existingTableRows(for: tabId) diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index dd4854fff3..be1327effe 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -65,7 +65,7 @@ struct RowOperationsManagerTests { #expect(tableRows.count == originalCount + 1) } - @Test("addNewRow returns correct row index and inserted delta") + @Test("addNewRow appends the row it names and reports an inserted delta") func addNewRowReturnsCorrectIndex() { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 5) @@ -73,7 +73,7 @@ struct RowOperationsManagerTests { let result = manager.addNewRow(tableRows: &tableRows) #expect(result != nil) - #expect(result?.rowIndex == 5) + #expect(result.flatMap { tableRows.index(of: $0.rowID) } == 5) if case .rowsInserted(let indices) = result?.delta { #expect(indices == IndexSet(integer: 5)) } else { @@ -88,9 +88,8 @@ struct RowOperationsManagerTests { let result = manager.addNewRow(tableRows: &tableRows) - #expect(result != nil) - let newIndex = result!.rowIndex - #expect(tableRows.rows[newIndex].id.isInserted) + #expect(result?.rowID.isInserted == true) + #expect(tableRows.rows.last?.id == result?.rowID) } @Test("addNewRow uses DEFAULT marker for columns with defaults") @@ -166,7 +165,7 @@ struct RowOperationsManagerTests { #expect(result != nil) #expect(changeManager.hasChanges) - #expect(changeManager.isRowInserted(result!.rowIndex)) + #expect(result.map { changeManager.isRowInserted($0.rowID) } == true) } @Test("addNewRow increments change manager reload version") @@ -199,9 +198,9 @@ struct RowOperationsManagerTests { let r3 = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == 5) - #expect(r1?.rowIndex == 2) - #expect(r2?.rowIndex == 3) - #expect(r3?.rowIndex == 4) + #expect(r1.flatMap { tableRows.index(of: $0.rowID) } == 2) + #expect(r2.flatMap { tableRows.index(of: $0.rowID) } == 3) + #expect(r3.flatMap { tableRows.index(of: $0.rowID) } == 4) } @Test("duplicateRow copies source row values") @@ -277,8 +276,8 @@ struct RowOperationsManagerTests { ) #expect(changeManager.hasChanges) - #expect(changeManager.isRowDeleted(1)) - #expect(changeManager.isRowDeleted(3)) + #expect(changeManager.isRowDeleted(.existing(1))) + #expect(changeManager.isRowDeleted(.existing(3))) } @Test("deleteSelectedRows removes inserted rows from tableRows and reports delta") @@ -286,17 +285,17 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 3) - let addResult = manager.addNewRow(tableRows: &tableRows) + _ = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == 4) let result = manager.deleteSelectedRows( - selectedIndices: [addResult!.rowIndex], + selectedIndices: [3], tableRows: &tableRows ) #expect(tableRows.count == 3) if case .rowsRemoved(let indices) = result.delta { - #expect(indices == IndexSet(integer: addResult!.rowIndex)) + #expect(indices == IndexSet(integer: 3)) } else { Issue.record("Expected .rowsRemoved delta") } @@ -319,6 +318,33 @@ struct RowOperationsManagerTests { #expect(result.nextRowToSelect < tableRows.count) } + @Test("deleteSelectedRows selects the row after the block, or the one before it at the end") + func deleteSelectedRowsNextSelection() { + let (manager, _) = makeManager() + + var fourRows = makeTableRows(rowCount: 4) + #expect(manager.deleteSelectedRows(selectedIndices: [1], tableRows: &fourRows).nextRowToSelect == 2) + + var fromTheEnd = makeTableRows(rowCount: 4) + #expect(manager.deleteSelectedRows(selectedIndices: [3], tableRows: &fromTheEnd).nextRowToSelect == 2) + + var block = makeTableRows(rowCount: 6) + #expect(manager.deleteSelectedRows(selectedIndices: [1, 2, 3], tableRows: &block).nextRowToSelect == 4) + } + + @Test("deleteSelectedRows counts the rows the removal took out before choosing the next one") + func deleteSelectedRowsNextSelectionAfterRemovals() { + let (manager, _) = makeManager() + var tableRows = makeTableRows(rowCount: 2) + _ = manager.addNewRow(tableRows: &tableRows) + _ = manager.addNewRow(tableRows: &tableRows) + + let result = manager.deleteSelectedRows(selectedIndices: [2, 3], tableRows: &tableRows) + + #expect(tableRows.count == 2) + #expect(result.nextRowToSelect == 1) + } + @Test("deleteSelectedRows returns empty result for empty selection") func deleteSelectedRowsEmptySelection() { let (manager, _) = makeManager() @@ -379,60 +405,89 @@ struct RowOperationsManagerTests { #expect(tableRows.count == 3) } - @Test("deleteRows marks an existing row deleted at its display index with the given values") - func deleteRowsMarksExistingByDisplayIndex() { + @Test("Under a value filter, a selected position deletes the row shown there") + func deleteSelectedRowsUnderFilterMarksTheShownRow() { let (manager, changeManager) = makeManager() var tableRows = makeTableRows(rowCount: 4) - let resolvedRow: [PluginCellValue] = [.text("42"), .text("Zoe"), .text("zoe@test.com")] + let displayIDs: [RowID] = [.existing(3), .existing(1)] + let shownRow = Array(tableRows.rows[1].values) - _ = manager.deleteRows( - existingRows: [(displayIndex: 1, originalRow: resolvedRow)], - insertedStorageIndices: [], - tableRows: &tableRows - ) + _ = manager.deleteSelectedRows(selectedIndices: [1], displayIDs: displayIDs, tableRows: &tableRows) - #expect(changeManager.isRowDeleted(1)) + #expect(changeManager.isRowDeleted(.existing(1))) + #expect(!changeManager.isRowDeleted(.existing(3))) #expect(tableRows.count == 4) - let deleteChange = changeManager.rowChanges.first { $0.type == .delete && $0.rowIndex == 1 } - #expect(deleteChange?.originalRow == resolvedRow) + let deleteChange = changeManager.rowChanges.first { $0.type == .delete } + #expect(deleteChange?.rowID == .existing(1)) + #expect(deleteChange?.originalRow == shownRow) } - @Test("deleteRows physically removes inserted rows by storage index") - func deleteRowsRemovesInsertedByStorageIndex() { - let (manager, _) = makeManager() + @Test("Under a value filter, a selected inserted row is removed from its storage position") + func deleteSelectedRowsUnderFilterRemovesInsertedRow() { + let (manager, changeManager) = makeManager() var tableRows = makeTableRows(rowCount: 3) guard let addResult = manager.addNewRow(tableRows: &tableRows) else { Issue.record("addNewRow returned nil") return } - #expect(tableRows.count == 4) + let displayIDs: [RowID] = [.existing(2), addResult.rowID] - let result = manager.deleteRows( - existingRows: [], - insertedStorageIndices: [addResult.rowIndex], - tableRows: &tableRows - ) + let result = manager.deleteSelectedRows(selectedIndices: [1], displayIDs: displayIDs, tableRows: &tableRows) #expect(tableRows.count == 3) - #expect(result.physicallyRemovedIndices == [addResult.rowIndex]) + #expect(tableRows.index(of: addResult.rowID) == nil) + #expect(!changeManager.isRowInserted(addResult.rowID)) + #expect(result.physicallyRemovedIndices == [3]) if case .rowsRemoved(let indices) = result.delta { - #expect(indices == IndexSet(integer: addResult.rowIndex)) + #expect(indices == IndexSet(integer: 3)) } else { Issue.record("Expected .rowsRemoved delta") } } + @Test("Undoing the removal of inserted rows puts them back where they were, with their identity") + func undoRemovedInsertedRowsRestoresThem() { + let (manager, changeManager) = makeManager() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + changeManager.undoManagerProvider = { undoManager } + var captured: UndoResult? + changeManager.onUndoApplied = { captured = $0 } + var tableRows = makeTableRows(rowCount: 2) + guard let first = manager.addNewRow(tableRows: &tableRows), + let second = manager.addNewRow(tableRows: &tableRows) else { + Issue.record("addNewRow returned nil") + return + } + + _ = manager.deleteSelectedRows(selectedIndices: [2, 3], tableRows: &tableRows) + #expect(tableRows.count == 2) + + undoManager.undo() + guard let captured else { + Issue.record("No undo result") + return + } + _ = manager.applyUndoResult(captured, tableRows: &tableRows) + + #expect(tableRows.index(of: first.rowID) == 2) + #expect(tableRows.index(of: second.rowID) == 3) + #expect(changeManager.isRowInserted(first.rowID)) + #expect(changeManager.isRowInserted(second.rowID)) + } + @Test("addNewRow then edit cell preserves insertion state") func addNewRowThenEditPreservesInsertion() { let (manager, changeManager) = makeManager() var tableRows = makeTableRows(rowCount: 2) - let result = manager.addNewRow(tableRows: &tableRows) - #expect(result != nil) - let newIndex = result!.rowIndex + guard let result = manager.addNewRow(tableRows: &tableRows) else { + Issue.record("addNewRow returned nil") + return + } changeManager.recordCellChange( - rowIndex: newIndex, + rowID: result.rowID, columnIndex: 1, columnName: "name", oldValue: nil, @@ -440,8 +495,8 @@ struct RowOperationsManagerTests { ) #expect(changeManager.hasChanges) - #expect(changeManager.isRowInserted(newIndex)) + #expect(changeManager.isRowInserted(result.rowID)) #expect(tableRows.count == 3) - #expect(tableRows.rows[newIndex].id.isInserted) + #expect(tableRows.row(withID: result.rowID) != nil) } } diff --git a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift index 3bacae282b..780fafb86f 100644 --- a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift @@ -112,4 +112,12 @@ struct TableQueryBuilderMSSQLTests { let query = fallback.buildBaseQuery(tableName: "users") #expect(query == "SELECT * FROM [users] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY") } + + @Test("The filtered row count spells a non-ASCII value the way the browse query does") + func filteredCountUsesNationalLiterals() { + let filters = [TestFixtures.makeTableFilter(column: "name", op: .equal, value: "日本語メール")] + let count = builder.buildFilteredCountQuery(tableName: "users", filters: filters, columns: ["name"]) + #expect(count?.contains("[name] = N'日本語メール'") == true) + #expect(count?.contains("= '日本語メール'") == false) + } } diff --git a/TableProTests/Core/Services/UndoRowIndexTests.swift b/TableProTests/Core/Services/UndoRowIdentityTests.swift similarity index 55% rename from TableProTests/Core/Services/UndoRowIndexTests.swift rename to TableProTests/Core/Services/UndoRowIdentityTests.swift index a0e972eef0..e2d19956ff 100644 --- a/TableProTests/Core/Services/UndoRowIndexTests.swift +++ b/TableProTests/Core/Services/UndoRowIdentityTests.swift @@ -1,12 +1,7 @@ // -// UndoRowIndexTests.swift +// UndoRowIdentityTests.swift // TableProTests // -// A cell edit is tracked by its display row, the same as the modified marks the grid draws, but -// TableRows is indexed by storage position. Undo used to write straight through with the display -// number, so with a value filter narrowing the grid it reverted whichever row happened to sit at -// that storage offset. -// import Foundation import TableProPluginKit @@ -14,8 +9,8 @@ import TableProPluginKit import Testing @MainActor -@Suite("Undo row indices") -struct UndoRowIndexTests { +@Suite("Undo row identity") +struct UndoRowIdentityTests { private static let columns = ["id", "name"] private func makeTableRows() -> TableRows { @@ -42,10 +37,10 @@ struct UndoRowIndexTests { return RowOperationsManager(changeManager: changeManager) } - private func cellEditUndo(displayRow: Int, previous: PluginCellValue) -> UndoResult { + private func cellEditUndo(rowID: RowID, previous: PluginCellValue) -> UndoResult { UndoResult( action: .cellEdit( - rowIndex: displayRow, + rowID: rowID, columnIndex: 1, columnName: "name", previousValue: previous, @@ -58,16 +53,13 @@ struct UndoRowIndexTests { ) } - /// Display 1 is storage 2 once the middle row is filtered out. - @Test("Undoing a cell edit under a value filter reverts the row that was edited") - func undoResolvesThroughTheFilter() { + @Test("Undoing a cell edit reverts the row that was edited") + func undoRevertsTheEditedRow() { var tableRows = makeTableRows() - let displayIDs = [tableRows.rows[0].id, tableRows.rows[2].id] tableRows.rows[2].values[1] = "edited" _ = makeManager().applyUndoResult( - cellEditUndo(displayRow: 1, previous: "keep"), - displayIDs: displayIDs, + cellEditUndo(rowID: tableRows.rows[2].id, previous: "keep"), tableRows: &tableRows ) @@ -75,33 +67,42 @@ struct UndoRowIndexTests { #expect(tableRows.rows[1].values[1] == "drop") } - @Test("With no filter the display row is the storage row, and nothing changes") - func undoWithoutFilterIsUnchanged() { + @Test("A row that is no longer loaded reverts nothing rather than the wrong row") + func undoForAMissingRowIsANoOp() { var tableRows = makeTableRows() - tableRows.rows[1].values[1] = "edited" + let before = tableRows.rows.map { $0.values } - _ = makeManager().applyUndoResult( - cellEditUndo(displayRow: 1, previous: "drop"), - displayIDs: nil, + let result = makeManager().applyUndoResult( + cellEditUndo(rowID: .existing(99), previous: "keep"), tableRows: &tableRows ) - #expect(tableRows.rows[1].values[1] == "drop") + #expect(result.delta == .none) + #expect(tableRows.rows.map { $0.values } == before) } - @Test("A display row the filter no longer shows reverts nothing rather than the wrong row") - func undoForAHiddenRowIsANoOp() { + @Test("Undoing an insertion removes that row, and redoing it restores the same row") + func insertionUndoRedoKeepsIdentity() { var tableRows = makeTableRows() - let displayIDs = [tableRows.rows[0].id] - let before = tableRows.rows.map { $0.values } + let rowID = RowID.inserted(UUID()) + _ = tableRows.appendInsertedRow(id: rowID, values: ["4", "new"]) + let manager = makeManager() - let result = makeManager().applyUndoResult( - cellEditUndo(displayRow: 5, previous: "keep"), - displayIDs: displayIDs, - tableRows: &tableRows + let removal = UndoResult( + action: .rowInsertion(rowID: rowID), needsRowRemoval: true, needsRowRestore: false, restoreRow: nil ) + let removed = manager.applyUndoResult(removal, tableRows: &tableRows) - #expect(result.delta == .none) - #expect(tableRows.rows.map { $0.values } == before) + #expect(tableRows.index(of: rowID) == nil) + #expect(removed.delta == .rowsRemoved(IndexSet(integer: 3))) + + let restore = UndoResult( + action: .rowInsertion(rowID: rowID), needsRowRemoval: false, needsRowRestore: true, + restoreRow: ["4", "new"] + ) + _ = manager.applyUndoResult(restore, tableRows: &tableRows) + + #expect(tableRows.index(of: rowID) == 3) + #expect(tableRows.row(withID: rowID)?.values == ["4", "new"]) } } diff --git a/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift b/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift index f165b45f65..a06b014ad2 100644 --- a/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift +++ b/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift @@ -19,6 +19,21 @@ struct ColumnLayoutSyncTests { return (FileColumnLayoutPersister(storageDirectory: directory, syncTracker: tracker), tracker) } + private func makeTrackedPersister() throws -> (FileColumnLayoutPersister, SyncMetadataStorage, URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cl-sync-\(UUID().uuidString)", isDirectory: true) + let meta = try #require(UserDefaults(suiteName: "cl-sync-meta-\(UUID().uuidString)")) + let metadata = SyncMetadataStorage(userDefaults: meta) + let tracker = SyncChangeTracker(metadataStorage: metadata) + return (FileColumnLayoutPersister(storageDirectory: directory, syncTracker: tracker), metadata, directory) + } + + private func layout(_ widths: [String: CGFloat]) -> ColumnLayoutState { + var state = ColumnLayoutState() + state.columnWidths = widths + return state + } + private func key() -> ColumnLayoutTableKey { ColumnLayoutTableKey(connectionId: UUID(), databaseName: "shop", schemaName: "public", tableName: "orders") } @@ -55,6 +70,68 @@ struct ColumnLayoutSyncTests { #expect(target.load(for: tableKey)?.columnOrder == ["id", "created_at"]) } + @Test("Deleting a connection removes its layout file and tombstones every layout it held") + func purgeConnectionsRemovesFileAndTombstones() throws { + let (persister, metadata, directory) = try makeTrackedPersister() + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let orders = ColumnLayoutTableKey( + connectionId: connectionId, databaseName: "shop", schemaName: "public", tableName: "orders" + ) + let items = ColumnLayoutTableKey( + connectionId: connectionId, databaseName: "shop", schemaName: "public", tableName: "items" + ) + let kept = ColumnLayoutTableKey( + connectionId: UUID(), databaseName: "shop", schemaName: "public", tableName: "orders" + ) + persister.save(layout(["id": 80]), for: orders) + persister.save(layout(["id": 90]), for: items) + persister.save(layout(["id": 70]), for: kept) + + persister.purgeConnections([connectionId]) + + let file = directory.appendingPathComponent("\(connectionId.uuidString).json") + #expect(!FileManager.default.fileExists(atPath: file.path)) + #expect(persister.load(for: orders) == nil) + #expect(persister.load(for: items) == nil) + #expect(persister.load(for: kept)?.columnWidths == ["id": 70]) + + let tombstones = Set(metadata.tombstones(for: .settings).map(\.id)) + #expect(tombstones == [ + FileColumnLayoutPersister.syncCategory(for: orders.storageKey), + FileColumnLayoutPersister.syncCategory(for: items.storageKey) + ]) + #expect(!metadata.dirtyIds(for: .settings).contains(FileColumnLayoutPersister.syncCategory(for: orders.storageKey))) + } + + @Test("A table rename moves its layout, tombstones the old record and marks the new one dirty") + func renameTableMovesLayoutAndSyncsIt() throws { + let (persister, metadata, directory) = try makeTrackedPersister() + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let old = TableScope(connectionId: connectionId, database: "shop", schema: "public", table: "orders") + let new = TableScope(connectionId: connectionId, database: "shop", schema: "public", table: "purchases") + let archive = ColumnLayoutTableKey( + connectionId: connectionId, databaseName: "shop", schemaName: "public", tableName: "orders_archive" + ) + let oldKey = ColumnLayoutTableKey( + connectionId: connectionId, databaseName: "shop", schemaName: "public", tableName: "orders" + ) + let newKey = ColumnLayoutTableKey( + connectionId: connectionId, databaseName: "shop", schemaName: "public", tableName: "purchases" + ) + persister.save(layout(["id": 80]), for: oldKey) + persister.save(layout(["id": 60]), for: archive) + + persister.renameTable(from: old, to: new) + + #expect(persister.load(for: oldKey) == nil) + #expect(persister.load(for: newKey)?.columnWidths == ["id": 80]) + #expect(persister.load(for: archive)?.columnWidths == ["id": 60]) + #expect(metadata.tombstones(for: .settings).map(\.id) == [FileColumnLayoutPersister.syncCategory(for: oldKey.storageKey)]) + #expect(metadata.dirtyIds(for: .settings).contains(FileColumnLayoutPersister.syncCategory(for: newKey.storageKey))) + } + @Test("The sync category carries the columnLayout prefix") func categoryPrefix() { #expect(FileColumnLayoutPersister.syncCategory(for: "abc").hasPrefix(FileColumnLayoutPersister.syncCategoryPrefix)) diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index 6a0636b9fc..487fb8d20b 100644 --- a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift @@ -131,7 +131,7 @@ struct FilterSettingsStorageTests { keptFilters, for: "users", connectionId: keptConnection, databaseName: "db", schemaName: nil ) - storage.removeFilters(for: deletedConnection) + storage.purgeConnections([deletedConnection]) storage.waitForPendingDiskWrites() #expect( @@ -158,7 +158,7 @@ struct FilterSettingsStorageTests { ) } - storage.removeFilters(for: [first, second]) + storage.purgeConnections([first, second]) storage.waitForPendingDiskWrites() #expect(storage.loadLastFilters(for: "users", connectionId: first, databaseName: "db", schemaName: nil).isEmpty) @@ -184,7 +184,7 @@ struct FilterSettingsStorageTests { for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil ) - storage.removeFilters(for: connectionId) + storage.purgeConnections([connectionId]) storage.waitForPendingDiskWrites() let fresh = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) @@ -395,4 +395,84 @@ struct FilterSettingsStorageTests { #expect(state.filters == filters) #expect(state.logicMode == .and) } + + @Test("A table rename moves its filters and browse search and leaves a longer name alone") + func renameTableMovesFiltersAndBrowseSearch() throws { + let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let filters = [TestFixtures.makeTableFilter(column: "email", value: "a@b.com")] + let archiveFilters = [TestFixtures.makeTableFilter(column: "id", value: "1")] + let search = BrowseSearchState(pattern: "user:*", typeScope: "hash") + + let storage = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + storage.saveLastFilters(filters, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + storage.saveLastFilters( + archiveFilters, for: "users_archive", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + storage.saveBrowseSearch(search, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + + storage.renameTable( + from: TableScope(connectionId: connectionId, database: "db", schema: nil, table: "users"), + to: TableScope(connectionId: connectionId, database: "db", schema: nil, table: "members") + ) + storage.waitForPendingDiskWrites() + + for reader in [storage, FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults)] { + #expect( + reader.loadLastFilters(for: "members", connectionId: connectionId, databaseName: "db", schemaName: nil) + == filters + ) + #expect( + reader.loadBrowseSearch(for: "members", connectionId: connectionId, databaseName: "db", schemaName: nil) + == search + ) + #expect( + reader.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + .isEmpty + ) + #expect( + reader.loadLastFilters( + for: "users_archive", connectionId: connectionId, databaseName: "db", schemaName: nil + ) == archiveFilters + ) + } + } + + @Test("A schema rename moves browse search along with the filters") + func renameContainerMovesBrowseSearch() throws { + let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let filters = [TestFixtures.makeTableFilter(column: "email", value: "a@b.com")] + let search = BrowseSearchState(pattern: "user:*", typeScope: "hash") + + let storage = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + storage.saveLastFilters(filters, for: "users", connectionId: connectionId, databaseName: "db", schemaName: "app") + storage.saveBrowseSearch(search, for: "users", connectionId: connectionId, databaseName: "db", schemaName: "app") + + storage.renameContainer( + connectionId: connectionId, fromDatabase: "db", fromSchema: "app", toDatabase: "db", toSchema: "core" + ) + storage.waitForPendingDiskWrites() + + for reader in [storage, FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults)] { + #expect( + reader.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: "core") + == filters + ) + #expect( + reader.loadBrowseSearch(for: "users", connectionId: connectionId, databaseName: "db", schemaName: "core") + == search + ) + #expect( + !reader.loadBrowseSearch(for: "users", connectionId: connectionId, databaseName: "db", schemaName: "app") + .isActive + ) + } + } } diff --git a/TableProTests/Core/Storage/HighlightRuleStorageTests.swift b/TableProTests/Core/Storage/HighlightRuleStorageTests.swift new file mode 100644 index 0000000000..da5be69e6b --- /dev/null +++ b/TableProTests/Core/Storage/HighlightRuleStorageTests.swift @@ -0,0 +1,146 @@ +// +// HighlightRuleStorageTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Highlight rule storage") +@MainActor +struct HighlightRuleStorageTests { + private let directory: URL + private let connectionId = UUID() + + init() { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("HighlightRuleStorageTests-\(UUID().uuidString)", isDirectory: true) + } + + private func scope(table: String, database: String? = "shop", schema: String? = "public") -> TableScope { + TableScope(connectionId: connectionId, database: database, schema: schema, table: table) + } + + private var fileURL: URL { + directory.appendingPathComponent("\(connectionId.uuidString).json") + } + + @Test("Rules round-trip through a fresh store") + func roundTrip() { + let rules = [ + HighlightRule(columnName: "status", value: "paid", color: .green), + HighlightRule(columnName: "total", filterOperator: .greaterThan, value: "10", color: .red, target: .cell) + ] + HighlightRuleStorage(storageDirectory: directory).setRules(rules, for: scope(table: "orders")) + + let reloaded = HighlightRuleStorage(storageDirectory: directory) + #expect(reloaded.rules(for: scope(table: "orders")) == rules) + #expect(reloaded.rules(for: scope(table: "customers")).isEmpty) + } + + @Test("Clearing the last rule removes the connection's file") + func clearingRemovesFile() { + let storage = HighlightRuleStorage(storageDirectory: directory) + storage.setRules([HighlightRule(columnName: "status", value: "paid")], for: scope(table: "orders")) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + storage.setRules([], for: scope(table: "orders")) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("A table rename moves its rules to the new name") + func renameMovesRules() { + let storage = HighlightRuleStorage(storageDirectory: directory) + let rules = [HighlightRule(columnName: "status", value: "paid")] + storage.setRules(rules, for: scope(table: "orders")) + + storage.renameTable(from: scope(table: "orders"), to: scope(table: "purchases")) + + let reloaded = HighlightRuleStorage(storageDirectory: directory) + #expect(reloaded.rules(for: scope(table: "orders")).isEmpty) + #expect(reloaded.rules(for: scope(table: "purchases")) == rules) + } + + @Test("A schema rename moves every table's rules in it") + func renameScopeMovesEveryTable() { + let storage = HighlightRuleStorage(storageDirectory: directory) + let rules = [HighlightRule(columnName: "status", value: "paid")] + storage.setRules(rules, for: scope(table: "orders")) + storage.setRules(rules, for: scope(table: "items")) + + storage.renameContainer( + connectionId: connectionId, fromDatabase: "shop", fromSchema: "public", + toDatabase: "shop", toSchema: "sales" + ) + + #expect(storage.rules(for: scope(table: "orders", schema: "sales")) == rules) + #expect(storage.rules(for: scope(table: "items", schema: "sales")) == rules) + #expect(storage.rules(for: scope(table: "orders")).isEmpty) + } + + @Test("Deleting a connection removes its rules") + func removingConnectionPurges() { + let storage = HighlightRuleStorage(storageDirectory: directory) + storage.setRules([HighlightRule(columnName: "status", value: "paid")], for: scope(table: "orders")) + + storage.purgeConnections([connectionId]) + + #expect(storage.rules(for: scope(table: "orders")).isEmpty) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("Deleting a connection removes a set-aside unreadable file too") + func purgeRemovesUnreadableFile() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("{ not json".utf8).write(to: fileURL) + let storage = HighlightRuleStorage(storageDirectory: directory) + _ = storage.rules(for: scope(table: "orders")) + let preserved = directory.appendingPathComponent("\(connectionId.uuidString).unreadable.json") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + + storage.purgeConnections([connectionId]) + + #expect(!FileManager.default.fileExists(atPath: preserved.path)) + } + + @Test("Every change moves the observed revision") + func revisionMoves() { + let storage = HighlightRuleStorage(storageDirectory: directory) + let before = storage.revision + storage.setRules([HighlightRule(columnName: "status", value: "paid")], for: scope(table: "orders")) + #expect(storage.revision != before) + } + + @Test("An unreadable file is set aside rather than overwritten") + func unreadableFileIsPreserved() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("{ not json".utf8).write(to: fileURL) + + let storage = HighlightRuleStorage(storageDirectory: directory) + #expect(storage.rules(for: scope(table: "orders")).isEmpty) + + let preserved = directory.appendingPathComponent("\(connectionId.uuidString).unreadable.json") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + #expect(try String(contentsOf: preserved, encoding: .utf8) == "{ not json") + } + + @Test("A rule the app cannot decode is skipped and the rest survive") + func undecodableRuleIsSkipped() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let key = scope(table: "orders").storageComponent + let json = """ + {"\(key)": [ + {"columnName": "status", "filterOperator": "=", "value": "paid", "color": "green"}, + {"columnName": "status", "filterOperator": "SOUNDS LIKE", "value": "x", "color": "green"}, + {"columnName": "status", "filterOperator": "=", "value": "late", "color": "chartreuse"} + ]} + """ + try Data(json.utf8).write(to: fileURL) + + let rules = HighlightRuleStorage(storageDirectory: directory).rules(for: scope(table: "orders")) + #expect(rules.count == 1) + #expect(rules.first?.value == "paid") + #expect(rules.first?.color == .green) + } +} diff --git a/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift index 08ff42797c..a4a0232bf2 100644 --- a/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift +++ b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift @@ -20,7 +20,7 @@ struct PreferenceKeysGuardTests { @Test("No off-namespace forKey: literals outside the frozen baseline") func noNewRawForKeyLiterals() throws { - let offenders = try Self.scan(pattern: #"forKey:\s*"([^"\\]+)""#) + let offenders = try Self.scan(pattern: #"forKey:\s*"([^"\\]+)""#, ignoringCalls: Self.nonPreferenceCalls) .filter { !$0.hasPrefix("com.TablePro") && Self.grandfatheredForKey[$0] == nil } #expect(offenders.isEmpty, "Route new UserDefaults keys through PreferenceKeys: \(offenders.sorted())") } @@ -32,12 +32,19 @@ struct PreferenceKeysGuardTests { #expect(offenders.isEmpty, "Route new @AppStorage keys through the preferences layer: \(offenders.sorted())") } + /// `forKey:` is not UserDefaults' label alone: `Dictionary.removeValue(forKey:)` and + /// `CALayer.add(_:forKey:)` spell it the same way, and a text scan cannot tell them apart. The + /// baseline grew one entry per dictionary key instead, three of its five, and the fourth arrived + /// as `removeValue(forKey: "LC_ALL")` in the dump environment (#2747), which failed this suite on + /// main for a value no preference has ever read. Naming the calls that are not preferences keeps + /// the baseline for the keys that genuinely are. + private static let nonPreferenceCalls: Set = [ + "removeValue", "updateValue", "add", "animation", "removeAnimation", + ] + private static let grandfatheredForKey: [String: String] = [ "AppleLanguages": "Apple system default written when switching app language", "NSTableViewDefaultSizeMode": "Apple system default read by the workspace rail for Sidebar icon size, never written", - "blink": "CALayer animation key in VimCursorManager, not a preference", - "extensions": "MCP capabilities field stripped by BridgeProxy for legacy clients, not a preference", - "preConnectScript": "additionalFields dictionary key in ConnectionFormCoordinator, not a preference", ] private static let grandfatheredAppStorage: [String: String] = [ @@ -46,7 +53,7 @@ struct PreferenceKeysGuardTests { "structureCodeFontSize": "legacy structure font size, migrates to PreferenceKeys in a later phase", ] - private static func scan(pattern: String) throws -> Set { + private static func scan(pattern: String, ignoringCalls ignored: Set = []) throws -> Set { let sourceRoot = try repoRoot().appendingPathComponent("TablePro") let regex = try NSRegularExpression(pattern: pattern) guard let enumerator = FileManager.default.enumerator( @@ -59,14 +66,50 @@ struct PreferenceKeysGuardTests { let text = try String(contentsOf: url, encoding: .utf8) let range = NSRange(text.startIndex..., in: text) for match in regex.matches(in: text, range: range) where match.numberOfRanges > 1 { - if let captured = Range(match.range(at: 1), in: text) { - matches.insert(String(text[captured])) - } + guard let captured = Range(match.range(at: 1), in: text), + let start = Range(match.range, in: text)?.lowerBound + else { continue } + if let call = enclosingCall(in: text, at: start), ignored.contains(call) { continue } + matches.insert(String(text[captured])) } } return matches } + /// The function whose argument list the match sits in, found by walking back to the innermost + /// unmatched `(`. Nested parentheses in an earlier argument are skipped, so + /// `layer.add(makeBlinkAnimation(), forKey: "blink")` reports `add` rather than + /// `makeBlinkAnimation`. + private static func enclosingCall(in text: String, at index: String.Index) -> String? { + var depth = 0 + var cursor = index + while cursor > text.startIndex { + cursor = text.index(before: cursor) + let character = text[cursor] + if character == ")" { + depth += 1 + } else if character == "(" { + if depth == 0 { return identifier(in: text, endingBefore: cursor) } + depth -= 1 + } else if character == "\n", depth == 0 { + continue + } + } + return nil + } + + private static func identifier(in text: String, endingBefore index: String.Index) -> String? { + var end = index + while end > text.startIndex { + let previous = text.index(before: end) + let character = text[previous] + guard character.isLetter || character.isNumber || character == "_" else { break } + end = previous + } + guard end < index else { return nil } + return String(text[end ..< index]) + } + private static func repoRoot() throws -> URL { var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() for _ in 0 ..< 12 { diff --git a/TableProTests/Core/Storage/TableScopedSettingsRegistryTests.swift b/TableProTests/Core/Storage/TableScopedSettingsRegistryTests.swift new file mode 100644 index 0000000000..d190573781 --- /dev/null +++ b/TableProTests/Core/Storage/TableScopedSettingsRegistryTests.swift @@ -0,0 +1,134 @@ +// +// TableScopedSettingsRegistryTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Table-scoped settings registry") +@MainActor +struct TableScopedSettingsRegistryTests { + @MainActor + private final class RecordingStore: TableScopedSettingsStore { + private(set) var purgedConnectionIds: [Set] = [] + + func renameTable(from oldScope: TableScope, to newScope: TableScope) {} + + func renameContainer( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) {} + + func purgeConnections(_ connectionIds: Set) { + purgedConnectionIds.append(connectionIds) + } + } + + @Test("Deleting a connection purges every table-scoped store once with every id") + func connectionPurgeReachesEveryStore() { + let first = RecordingStore() + let second = RecordingStore() + let deleted: Set = [UUID(), UUID()] + + ConnectionLocalState.purge(connectionIds: deleted, origin: .remote, tableScopedStores: [first, second]) + + #expect(first.purgedConnectionIds == [deleted]) + #expect(second.purgedConnectionIds == [deleted]) + } + + @Test("An empty delete purges nothing") + func emptyPurgeReachesNoStore() { + let store = RecordingStore() + + ConnectionLocalState.purge(connectionIds: [], origin: .remote, tableScopedStores: [store]) + + #expect(store.purgedConnectionIds.isEmpty) + } + + @Test("Every store keyed by a table scope conforms and is registered") + func everyTableScopedStoreIsRegistered() throws { + let root = try Self.repoRoot() + let storageDirectory = root.appendingPathComponent("TablePro/Core/Storage", isDirectory: true) + let registry = try String( + contentsOf: storageDirectory.appendingPathComponent("TableScopedSettingsStore.swift"), + encoding: .utf8 + ) + + let keyUsage = try NSRegularExpression(pattern: #"\b(TableScope|CompositeStorageKey|ColumnLayoutTableKey)\b"#) + let classDeclaration = try NSRegularExpression(pattern: #"\bclass\s+([A-Z]\w*)"#) + var storeTypes: Set = [] + for text in try Self.swiftSources(under: storageDirectory) where Self.matches(keyUsage, in: text) { + storeTypes.formUnion(Self.captures(classDeclaration, in: text)) + } + + let conformance = try NSRegularExpression( + pattern: #"\b(?:class|extension)\s+([A-Z]\w*)\s*:[^{]*\bTableScopedSettingsStore\b"# + ) + var conformingTypes: Set = [] + for text in try Self.swiftSources(under: root.appendingPathComponent("TablePro", isDirectory: true)) { + conformingTypes.formUnion(Self.captures(conformance, in: text)) + } + + #expect(storeTypes.isSuperset(of: [ + "FilterSettingsStorage", + "FileColumnLayoutPersister", + "HighlightRuleStorage", + "ValueDisplayFormatStorage", + "ForeignKeyLabelColumnStore" + ])) + let unconformed = storeTypes.subtracting(conformingTypes) + #expect( + unconformed.isEmpty, + "A store keyed by table scope must conform to TableScopedSettingsStore: \(unconformed.sorted())" + ) + let unregistered = storeTypes.filter { !registry.contains("\($0).shared") } + #expect( + unregistered.isEmpty, + "A table-scoped store must be listed in TableScopedSettingsRegistry.stores: \(unregistered.sorted())" + ) + } + + private static func matches(_ regex: NSRegularExpression, in text: String) -> Bool { + regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil + } + + private static func captures(_ regex: NSRegularExpression, in text: String) -> Set { + let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) + return Set(matches.compactMap { match in + Range(match.range(at: 1), in: text).map { String(text[$0]) } + }) + } + + private static func swiftSources(under directory: URL) throws -> [String] { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { return [] } + + var sources: [String] = [] + for case let url as URL in enumerator where url.pathExtension == "swift" { + sources.append(try String(contentsOf: url, encoding: .utf8)) + } + return sources + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw GuardError.repoRootNotFound + } + + private enum GuardError: Error { + case repoRootNotFound + } +} diff --git a/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift b/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift index 7f4e0508d3..6be6b73ed8 100644 --- a/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift +++ b/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift @@ -87,4 +87,100 @@ struct ValueDisplayFormatStorageTests { #expect(defaults.data(forKey: legacyKey) == nil) #expect(defaults.data(forKey: PreferenceKeys.columnDisplayFormats(target).name) != nil) } + + @Test("A table rename moves its formats and leaves a longer name alone") + func renameTableMovesOnlyThatTable() throws { + let (storage, _) = try makeStorage() + let conn = UUID() + let other = UUID() + storage.save(["id": .uuid], for: scope("public", connectionId: conn)) + storage.save(["ref": .json], for: scope("public", connectionId: conn, table: "orders_archive")) + storage.save(["id": .json], for: scope("public", connectionId: other)) + + storage.renameTable( + from: scope("public", connectionId: conn), + to: scope("public", connectionId: conn, table: "purchases") + ) + + #expect(storage.load(for: scope("public", connectionId: conn)) == nil) + #expect(storage.load(for: scope("public", connectionId: conn, table: "purchases")) == ["id": .uuid]) + #expect(storage.load(for: scope("public", connectionId: conn, table: "orders_archive")) == ["ref": .json]) + #expect(storage.load(for: scope("public", connectionId: other)) == ["id": .json]) + } + + @Test("A table rename carries formats still stored under the legacy key") + func renameTableMigratesLegacy() throws { + let (storage, defaults) = try makeStorage() + let conn = UUID() + let legacyKey = "com.TablePro.columns.displayFormat.\(conn.uuidString).orders" + defaults.set(try JSONEncoder().encode(["id": ValueDisplayFormat.uuid]), forKey: legacyKey) + + storage.renameTable( + from: scope("public", connectionId: conn), + to: scope("public", connectionId: conn, table: "purchases") + ) + + #expect(defaults.data(forKey: legacyKey) == nil) + #expect(storage.load(for: scope("public", connectionId: conn, table: "purchases")) == ["id": .uuid]) + #expect(storage.load(for: scope("public", connectionId: conn)) == nil) + } + + @Test("A schema rename moves every table in it and nothing outside it") + func renameContainerMovesTheSchema() throws { + let (storage, _) = try makeStorage() + let conn = UUID() + let other = UUID() + storage.save(["id": .uuid], for: scope("public", connectionId: conn)) + storage.save(["ref": .json], for: scope("public", connectionId: conn, table: "items")) + storage.save(["id": .json], for: scope("public_old", connectionId: conn)) + storage.save(["id": .uuid], for: scope("public", connectionId: other)) + + storage.renameContainer( + connectionId: conn, fromDatabase: "shop", fromSchema: "public", toDatabase: "shop", toSchema: "sales" + ) + + #expect(storage.load(for: scope("sales", connectionId: conn)) == ["id": .uuid]) + #expect(storage.load(for: scope("sales", connectionId: conn, table: "items")) == ["ref": .json]) + #expect(storage.load(for: scope("public", connectionId: conn)) == nil) + #expect(storage.load(for: scope("public_old", connectionId: conn)) == ["id": .json]) + #expect(storage.load(for: scope("public", connectionId: other)) == ["id": .uuid]) + } + + @Test("A database rename moves every schema in it") + func renameDatabaseMovesEverySchema() throws { + let (storage, _) = try makeStorage() + let conn = UUID() + storage.save(["id": .uuid], for: scope("public", connectionId: conn)) + storage.save(["id": .json], for: scope("archive", connectionId: conn)) + let longerName = TableScope(connectionId: conn, database: "shopping", schema: "public", table: "orders") + storage.save(["id": .text], for: longerName) + + storage.renameContainer( + connectionId: conn, fromDatabase: "shop", fromSchema: nil, toDatabase: "store", toSchema: nil + ) + + let moved = TableScope(connectionId: conn, database: "store", schema: "public", table: "orders") + let movedArchive = TableScope(connectionId: conn, database: "store", schema: "archive", table: "orders") + #expect(storage.load(for: moved) == ["id": .uuid]) + #expect(storage.load(for: movedArchive) == ["id": .json]) + #expect(storage.load(for: longerName) == ["id": .text]) + #expect(storage.load(for: scope("public", connectionId: conn)) == nil) + } + + @Test("Deleting a connection removes its formats, legacy keys included, and keeps the rest") + func purgeConnectionsRemovesOnlyThatConnection() throws { + let (storage, defaults) = try makeStorage() + let conn = UUID() + let other = UUID() + let legacyKey = "com.TablePro.columns.displayFormat.\(conn.uuidString).customers" + defaults.set(try JSONEncoder().encode(["id": ValueDisplayFormat.uuid]), forKey: legacyKey) + storage.save(["id": .uuid], for: scope("public", connectionId: conn)) + storage.save(["id": .json], for: scope("public", connectionId: other)) + + storage.purgeConnections([conn]) + + #expect(defaults.data(forKey: PreferenceKeys.columnDisplayFormats(scope("public", connectionId: conn)).name) == nil) + #expect(defaults.data(forKey: legacyKey) == nil) + #expect(storage.load(for: scope("public", connectionId: other)) == ["id": .json]) + } } diff --git a/TableProTests/Core/Sync/SyncChangeTrackerTests.swift b/TableProTests/Core/Sync/SyncChangeTrackerTests.swift index 749b5e412e..660b6829cb 100644 --- a/TableProTests/Core/Sync/SyncChangeTrackerTests.swift +++ b/TableProTests/Core/Sync/SyncChangeTrackerTests.swift @@ -4,8 +4,8 @@ // import Foundation -import Testing import TableProSyncTransport +import Testing @testable import TablePro @@ -15,9 +15,9 @@ struct SyncChangeTrackerTests { private let metadata: SyncMetadataStorage private let tracker: SyncChangeTracker - init() { + init() throws { let unique = UUID().uuidString - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.SyncChangeTracker.\(unique)")! + let syncDefaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.SyncChangeTracker.\(unique)")) metadata = SyncMetadataStorage(userDefaults: syncDefaults) tracker = SyncChangeTracker(metadataStorage: metadata) } @@ -49,6 +49,34 @@ struct SyncChangeTrackerTests { #expect(metadata.tombstones(for: .connection).contains { $0.id == "conn-1" }) } + @Test("markDeleted with multiple ids clears each dirty flag and tombstones each id once") + func markDeletedMultiple() { + tracker.markDirty(.settings, ids: ["a", "b", "kept"]) + tracker.markDeleted(.settings, ids: ["a", "b"]) + + #expect(tracker.dirtyRecords(for: .settings) == ["kept"]) + #expect(metadata.tombstones(for: .settings).map(\.id).sorted() == ["a", "b"]) + } + + @Test("markDeleted with an empty id list records nothing") + func markDeletedEmptyIsNoop() { + tracker.markDirty(.settings, id: "kept") + tracker.markDeleted(.settings, ids: []) + + #expect(tracker.dirtyRecords(for: .settings) == ["kept"]) + #expect(metadata.tombstones(for: .settings).isEmpty) + } + + @Test("Suppression makes a batch markDeleted a no-op") + func suppressionDisablesBatchDelete() { + tracker.markDirty(.settings, id: "a") + tracker.isSuppressed = true + tracker.markDeleted(.settings, ids: ["a"]) + + #expect(tracker.dirtyRecords(for: .settings) == ["a"]) + #expect(metadata.tombstones(for: .settings).isEmpty) + } + @Test("Suppression makes markDirty and markDeleted no-ops") func suppressionDisablesTracking() { tracker.isSuppressed = true diff --git a/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift new file mode 100644 index 0000000000..f05642df9b --- /dev/null +++ b/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Connection URL Parser - OceanBase") +struct ConnectionURLParserOceanBaseTests { + @Test("Full oceanbase URL with default port") + func testFullURLDefaultPort() { + let result = ConnectionURLParser.parse("oceanbase://root%40sys:pass@host:2881/test") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.host == "host") + #expect(parsed.port == nil) + #expect(parsed.database == "test") + #expect(parsed.username == "root@sys") + #expect(parsed.password == "pass") + } + + @Test("Case-insensitive OceanBase scheme") + func testCaseInsensitiveScheme() { + let result = ConnectionURLParser.parse("OceanBase://root%40sys@host/db") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.host == "host") + #expect(parsed.username == "root@sys") + } + + @Test("OceanBase non-default port preserved") + func testNonDefaultPortPreserved() { + let result = ConnectionURLParser.parse("oceanbase://root%40sys:pass@host:2883/db") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.port == 2_883) + #expect(parsed.host == "host") + #expect(parsed.database == "db") + } +} diff --git a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift index 6003e96c7b..d087d77d2c 100644 --- a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift +++ b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift @@ -61,6 +61,16 @@ struct DatabaseURLSchemeTests { #expect(parsed.type == .tidb) } + @Test("OceanBase scheme parses successfully") + func oceanbaseScheme() { + let result = ConnectionURLParser.parse("oceanbase://root%40sys:pass@localhost:2881/test") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.username == "root@sys") + } + @Test("SQLite scheme parses successfully") func sqliteScheme() { let result = ConnectionURLParser.parse("sqlite:///path/to/database.db") @@ -196,6 +206,17 @@ struct DatabaseURLSchemeTests { #expect(parsed.sshHost == "sshhost") } + @Test("OceanBase+SSH scheme parses successfully") + func oceanbaseSshScheme() { + let result = ConnectionURLParser.parse("oceanbase+ssh://sshuser@sshhost:22/root%40sys:dbpass@dbhost/dbname") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.sshHost == "sshhost") + #expect(parsed.username == "root@sys") + } + // MARK: - Unsupported Schemes @Test("FTP scheme returns unsupported error") diff --git a/TableProTests/Core/Utilities/DisplayedResultReaderTests.swift b/TableProTests/Core/Utilities/DisplayedResultReaderTests.swift index 6b710b526f..7c0050c902 100644 --- a/TableProTests/Core/Utilities/DisplayedResultReaderTests.swift +++ b/TableProTests/Core/Utilities/DisplayedResultReaderTests.swift @@ -33,14 +33,14 @@ struct DisplayedResultReaderTests { private func read( displayIDs: [RowID]? = nil, selected: Set = [], - deleted: Set = [], + deleted: Set = [], columns: VisibleColumnProjection = .identity ) -> DisplayedResultReader.Output { DisplayedResultReader.read( tableRows: makeTableRows(), displayIDs: displayIDs, selectedDisplayIndices: selected, - deletedDisplayIndices: deleted, + deletedRowIDs: deleted, columns: columns ) } @@ -83,12 +83,20 @@ struct DisplayedResultReaderTests { @Test("A row marked for deletion is left out and counted") func deletedRowsAreSkipped() { - let output = read(deleted: [1]) + let output = read(deleted: [.existing(1)]) #expect(texts(output) == [["a", "1"], ["c", "3"]]) #expect(output.skippedDeletedCount == 1) } + @Test("A deletion is matched by the row it was made to, whatever position the filter gives it") + func deletedRowsFollowTheirRowUnderAFilter() { + let output = read(displayIDs: [.existing(2), .existing(0)], deleted: [.existing(0)]) + + #expect(texts(output) == [["c", "3"]]) + #expect(output.skippedDeletedCount == 1) + } + @Test("Hidden and reordered columns follow the grid") func columnProjectionIsApplied() { let output = read(columns: VisibleColumnProjection(indices: [1])) diff --git a/TableProTests/Core/Utilities/InClauseConverterTests.swift b/TableProTests/Core/Utilities/InClauseConverterTests.swift index 6c906b8083..47218b5f38 100644 --- a/TableProTests/Core/Utilities/InClauseConverterTests.swift +++ b/TableProTests/Core/Utilities/InClauseConverterTests.swift @@ -14,9 +14,34 @@ struct InClauseConverterTests { private func makeConverter( columnIndex: Int, columnTypes: [ColumnType], - escape: ((String) -> String)? = nil + escape: ((String) -> String)? = nil, + stringLiteralPrefix: String = "" ) -> InClauseConverter { - InClauseConverter(columnIndex: columnIndex, columnTypes: columnTypes, escapeStringLiteral: escape) + InClauseConverter( + columnIndex: columnIndex, + columnTypes: columnTypes, + escapeStringLiteral: escape, + stringLiteralPrefix: stringLiteralPrefix + ) + } + + /// An `IN` list pasted into a query on a SQL Server database with a non-Unicode collation + /// matched the rows whose text the server had already damaged, never the rows copied. + @Test("A text value carries the engine's literal prefix, and a number does not") + func textValuesCarryThePrefix() { + let converter = makeConverter( + columnIndex: 0, + columnTypes: [.text(rawType: nil)], + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(.mssql) + ) + #expect(converter.generateInClause(rows: [[.text("日本語")], [.text("b")]]) == "(N'日本語', N'b')") + + let numbers = makeConverter( + columnIndex: 0, + columnTypes: [.integer(rawType: nil)], + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(.mssql) + ) + #expect(numbers.generateInClause(rows: [[.text("1")], [.text("2")]]) == "(1, 2)") } @Test("Empty rows yields empty parens") diff --git a/TableProTests/Core/Utilities/ResultJsonSerializerTests.swift b/TableProTests/Core/Utilities/ResultJsonSerializerTests.swift index 03658d5dff..01b875e11c 100644 --- a/TableProTests/Core/Utilities/ResultJsonSerializerTests.swift +++ b/TableProTests/Core/Utilities/ResultJsonSerializerTests.swift @@ -26,7 +26,7 @@ struct ResultJsonSerializerTests { private func serialize( displayIDs: [RowID]? = nil, selected: Set = [], - deleted: Set? = nil + deleted: Set? = nil ) -> ResultJsonSerializer.Output { guard let deleted else { return ResultJsonSerializer.serialize( @@ -40,7 +40,7 @@ struct ResultJsonSerializerTests { tableRows: makeTableRows(), displayIDs: displayIDs, selectedDisplayIndices: selected, - deletedDisplayIndices: deleted, + deletedRowIDs: deleted, columns: .identity ) } @@ -55,9 +55,9 @@ struct ResultJsonSerializerTests { #expect(withoutParameter.json.contains("\"b\"")) } - @Test("a supplied deleted position is skipped") - func deletedPositionIsSkipped() { - let output = serialize(deleted: [1]) + @Test("a supplied deleted row is skipped") + func deletedRowIsSkipped() { + let output = serialize(deleted: [.existing(1)]) #expect(output.rowCount == 2) #expect(output.json.contains("\"a\"")) @@ -65,9 +65,9 @@ struct ResultJsonSerializerTests { #expect(output.json.contains("\"c\"")) } - @Test("deleted positions are display positions, not storage indices") - func deletedPositionsAreDisplayPositions() { - let output = serialize(displayIDs: [.existing(2), .existing(1)], deleted: [0]) + @Test("a deletion names a row, not the position a value filter gives it") + func deletionsNameRowsNotPositions() { + let output = serialize(displayIDs: [.existing(2), .existing(1)], deleted: [.existing(2)]) #expect(output.rowCount == 1) #expect(output.json.contains("\"b\"")) @@ -76,7 +76,7 @@ struct ResultJsonSerializerTests { @Test("deleting every displayed row leaves an empty array") func deletingEverythingLeavesAnEmptyArray() { - let output = serialize(deleted: [0, 1, 2]) + let output = serialize(deleted: [.existing(0), .existing(1), .existing(2)]) #expect(output.rowCount == 0) #expect(output.json == "[]") diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift index 6b5ad141ab..704293ee76 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift @@ -545,6 +545,52 @@ struct QueryClassifierNonSqlTests { ) } + @Test("Weaviate GraphQL reads are safe and uuid deletes are destructive") + func weaviateTiers() { + #expect(!QueryClassifier.isWriteQuery("{ Get { Article { title } } }", databaseType: .weaviate)) + #expect(!QueryClassifier.isWriteQuery("query { Get { Article { title } } }", databaseType: .weaviate)) + #expect(!QueryClassifier.isWriteQuery("GET /v1/schema", databaseType: .weaviate)) + #expect(!QueryClassifier.isWriteQuery("POST /v1/graphql {}", databaseType: .weaviate)) + #expect(QueryClassifier.isWriteQuery("mutation { delete { Article } }", databaseType: .weaviate)) + #expect(QueryClassifier.isWriteQuery("POST /v1/objects {}", databaseType: .weaviate)) + #expect(QueryClassifier.classifyTier("DELETE /v1/objects/abc", databaseType: .weaviate) == .destructive) + let search = "WEAVIATE_SEARCH:e30=" + #expect(!QueryClassifier.isWriteQuery(search, databaseType: .weaviate)) + } + + @Test("A console body declaring a mutation is classified the same as a bare one") + func weaviateConsoleBodyIsRead() { + #expect(QueryClassifier.isWriteQuery( + "POST /v1/graphql\nmutation { delete { Article } }", + databaseType: .weaviate + )) + #expect(QueryClassifier.isWriteQuery( + "POST /v1/graphql\n{\"query\": \"mutation { delete { Article } }\"}", + databaseType: .weaviate + )) + #expect(!QueryClassifier.isWriteQuery( + "POST /v1/graphql\n{\"query\": \"{ Get { Article { title } } }\"}", + databaseType: .weaviate + )) + #expect(!QueryClassifier.isWriteQuery( + "POST /v1/graphql?pretty\n{ Get { Article { title } } }", + databaseType: .weaviate + )) + } + + @Test("A mutation inside a query envelope is a write however it is typed") + func weaviateEnvelopeIsRead() { + #expect(QueryClassifier.isWriteQuery( + "{\"query\": \"mutation { delete { Article } }\"}", + databaseType: .weaviate + )) + #expect(!QueryClassifier.isWriteQuery( + "{\"query\": \"{ Get { Article { title } } }\"}", + databaseType: .weaviate + )) + #expect(!QueryClassifier.isWriteQuery("{ Get { Article { title } } }", databaseType: .weaviate)) + } + @Test("Elasticsearch stored scripts are flagged as code execution on both verbs") func elasticsearchScriptsAreFlagged() { #expect( diff --git a/TableProTests/Core/Utilities/SQL/SQLChunkDecoderTests.swift b/TableProTests/Core/Utilities/SQL/SQLChunkDecoderTests.swift new file mode 100644 index 0000000000..4f4d640eb2 --- /dev/null +++ b/TableProTests/Core/Utilities/SQL/SQLChunkDecoderTests.swift @@ -0,0 +1,112 @@ +// +// SQLChunkDecoderTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("SQL chunk decoding") +struct SQLChunkDecoderTests { + private func decodeInChunks(_ data: Data, encoding: String.Encoding, chunk size: Int) -> String? { + var decoder = SQLChunkDecoder(encoding: encoding) + var text = "" + var offset = 0 + while offset < data.count { + let end = min(offset + size, data.count) + guard let piece = decoder.decode(data.subdata(in: offset.. [String] { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sql") + try data.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + var statements: [String] = [] + let parser = SQLFileParser() + for try await (stmt, _) in parser.parseFile(url: url, encoding: encoding, dialect: .mysql) { + statements.append(stmt) + } + return statements + } + + /// The parser reads 64 KiB at a time, and a chunk of UTF-16 without a byte order mark decodes + /// as big-endian, so every statement past the first boundary used to arrive byte-swapped: a + /// dump this size imported as CJK from 64 KiB on, with nothing raised. + @Test("A UTF-16 dump larger than one chunk parses to the statements it holds") + func utf16DumpLargerThanOneChunk() async throws { + let lines = (0..<4_000).map { "INSERT INTO t (a) VALUES ('メール \($0)');" } + let sql = lines.joined(separator: "\n") + "\n" + let body = try #require(sql.data(using: .utf16LittleEndian)) + #expect(body.count > 65_536 * 2) + + let withMark = try await Self.parse(Data([0xFF, 0xFE]) + body, encoding: .utf16) + #expect(withMark == lines.map { String($0.dropLast()) }) + + let withoutMark = try await Self.parse(body, encoding: .utf16LittleEndian) + #expect(withoutMark == lines.map { String($0.dropLast()) }) + } + + /// A multi-byte character landing on a chunk boundary is the ordinary case in a dump of + /// non-Latin text, and only UTF-8 used to carry one across. + @Test("A Shift JIS dump larger than one chunk parses to the statements it holds") + func shiftJISDumpLargerThanOneChunk() async throws { + let lines = (0..<4_000).map { "INSERT INTO t (a) VALUES ('日本語 \($0)');" } + let sql = lines.joined(separator: "\n") + "\n" + let body = try #require(sql.data(using: .shiftJIS)) + #expect(body.count > 65_536) + #expect(try await Self.parse(body, encoding: .shiftJIS) == lines.map { String($0.dropLast()) }) + } } diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 9a32963ab2..d7361a3292 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -152,11 +152,24 @@ struct SQLRowToStatementConverterTests { #expect(result == "UPDATE `users` SET `name` = 'Alice', `email` = 'alice@example.com' WHERE `id` = '1';") } - @Test("MSSQL uses bracket quoting") + /// The `N` is not decoration: a plain `'…'` is a `varchar` literal, so pasting these + /// statements into a database with a non-Unicode collation stores `?` for every character + /// outside its code page, whatever the column type is. + @Test("MSSQL uses bracket quoting and N-prefixed literals") func mssqlUsesBracketQuoting() throws { let converter = try makeConverter(databaseType: .mssql, dialect: Self.mssqlDialect) let result = converter.generateInserts(rows: [["1", "Alice", "alice@example.com"]]) - #expect(result == "INSERT INTO [users] ([id], [name], [email]) VALUES ('1', 'Alice', 'alice@example.com');") + #expect(result == "INSERT INTO [users] ([id], [name], [email]) VALUES (N'1', N'Alice', N'alice@example.com');") + } + + @Test("MSSQL: non-Latin text survives a copied INSERT and UPDATE") + func mssqlKeepsNonLatinText() throws { + let converter = try makeConverter(databaseType: .mssql, dialect: Self.mssqlDialect) + let inserts = converter.generateInserts(rows: [["1", "日本語", "a@b.c"]]) + #expect(inserts.contains("N'日本語'")) + let updates = converter.generateUpdates(rows: [["1", "日本語", "a@b.c"]]) + #expect(updates.contains("[name] = N'日本語'")) + #expect(updates.contains("WHERE [id] = N'1'")) } @Test("PostgreSQL uses double-quote quoting") diff --git a/TableProTests/Database/NativeDumpBatchTests.swift b/TableProTests/Database/NativeDumpBatchTests.swift index 5d88671bd5..e024c34c42 100644 --- a/TableProTests/Database/NativeDumpBatchTests.swift +++ b/TableProTests/Database/NativeDumpBatchTests.swift @@ -56,7 +56,7 @@ private final class FakeDumpService: NativeDumpRunning { } private func substituted(_ state: NativeDumpState, database: String, fileURL: URL) -> NativeDumpState { - guard case .finished(_, _, let bytes) = state else { return state } + guard case .finished(_, _, let bytes, _) = state else { return state } return .finished(database: database, fileURL: fileURL, bytesProcessed: bytes) } diff --git a/TableProTests/Database/NativeDumpRegistryTests.swift b/TableProTests/Database/NativeDumpRegistryTests.swift index ebc5e0a6eb..9790dbb39e 100644 --- a/TableProTests/Database/NativeDumpRegistryTests.swift +++ b/TableProTests/Database/NativeDumpRegistryTests.swift @@ -73,6 +73,24 @@ struct NativeDumpRegistryTests { } } + @Test("PostgreSQL matches its tools to the server, Redshift and MySQL keep the plain lookup") + func postgresToolsFollowTheServer() throws { + let postgres = try #require(NativeDumpRegistry.descriptor(for: .postgresql)?.commandLineTool) + let redshift = try #require(NativeDumpRegistry.descriptor(for: .redshift)?.commandLineTool) + let mysql = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + #expect(postgres.toolForServer != nil) + #expect(redshift.toolForServer == nil) + #expect(mysql.toolForServer == nil) + } + + @Test("One selector covers both directions, so a restore is matched to the server too") + func postgresRestoreUsesTheSameSelector() throws { + let postgres = try #require(NativeDumpRegistry.descriptor(for: .postgresql)?.commandLineTool) + #expect(postgres.binaries(for: .backup) == ["pg_dump"]) + #expect(postgres.binaries(for: .restore) == ["pg_restore"]) + #expect(postgres.toolForServer != nil) + } + @Test("Each engine offers its own archive extension") func archiveExtensions() throws { #expect(try #require(NativeDumpRegistry.descriptor(for: .postgresql)).archiveFormat.fileExtension == "dump") diff --git a/TableProTests/Database/NativeDumpServiceTests.swift b/TableProTests/Database/NativeDumpServiceTests.swift index 3e7c88f922..ef7b122fa4 100644 --- a/TableProTests/Database/NativeDumpServiceTests.swift +++ b/TableProTests/Database/NativeDumpServiceTests.swift @@ -98,6 +98,92 @@ struct NativeDumpServiceCommandTests { #expect(!command.arguments.contains("-f")) } + @Test("pg_restore tolerates unrecognized session settings; pg_dump does not") + func postgresExitPolicies() throws { + let restore = try NativeDumpService.buildCommand( + kind: .restore, + tool: postgresTool, + executable: URL(fileURLWithPath: "/usr/bin/pg_restore"), + request: request(connection: connection(), fileURL: URL(fileURLWithPath: "/tmp/sales.dump")) + ) + let backup = try NativeDumpService.buildCommand( + kind: .backup, + tool: postgresTool, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + request: request(connection: connection(), fileURL: URL(fileURLWithPath: "/tmp/sales.dump")) + ) + #expect(restore.exitPolicy == .toleratesUnrecognizedSessionSettings) + #expect(backup.exitPolicy == .zeroExitOnly) + } + + @Test("Every other engine's restore keeps the strict exit policy") + func otherEnginesStayStrict() throws { + for type in [DatabaseType.mysql, .mongodb, .sqlite, .mssql] { + let tool = try #require(NativeDumpRegistry.descriptor(for: type)?.commandLineTool) + #expect(tool.exitPolicy(for: .restore) == .zeroExitOnly) + #expect(tool.exitPolicy(for: .backup) == .zeroExitOnly) + } + } + + @Test("A job the engine runs for itself never tolerates a failure") + func statementJobsStayStrict() { + let job = NativeDumpJob.statements( + NativeDumpStatementJob( + statements: ["SELECT 1"], + cleanupStatements: [], + scope: DatabaseScope(connectionId: UUID(), database: "sales", schema: nil) + ) + ) + #expect(job.exitPolicy == .zeroExitOnly) + } + + @Test("pg_dump and pg_restore print untranslated messages") + func postgresToolsPrintUntranslatedMessages() throws { + for kind in [NativeDumpKind.backup, .restore] { + let command = try NativeDumpService.buildCommand( + kind: kind, + tool: postgresTool, + executable: URL(fileURLWithPath: "/usr/bin/pg_restore"), + request: request(connection: connection(), fileURL: URL(fileURLWithPath: "/tmp/sales.dump")) + ) + #expect(command.environment["LC_MESSAGES"] == "C") + #expect(command.environment["LC_ALL"] == nil) + } + } + + @Test("Other engines' tools keep the user's message language") + func otherToolsKeepTheirLanguage() throws { + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + #expect(!tool.requiresUntranslatedMessages) + } + + @Test("LC_ALL moves to LC_CTYPE so character handling survives, and messages become untranslated") + func untranslatedMessagesMovesLcAll() { + let environment = NativeDumpService.untranslatedMessagesEnvironment( + ["LANG": "fr_FR.UTF-8", "LC_ALL": "fr_FR.UTF-8", "PATH": "/usr/bin"] + ) + #expect(environment == [ + "LANG": "fr_FR.UTF-8", + "LC_CTYPE": "fr_FR.UTF-8", + "LC_MESSAGES": "C", + "PATH": "/usr/bin" + ]) + } + + @Test("An LC_CTYPE already present is kept over LC_ALL") + func untranslatedMessagesKeepsLcCtype() { + let environment = NativeDumpService.untranslatedMessagesEnvironment( + ["LC_ALL": "fr_FR.UTF-8", "LC_CTYPE": "de_DE.UTF-8"] + ) + #expect(environment == ["LC_CTYPE": "de_DE.UTF-8", "LC_MESSAGES": "C"]) + } + + @Test("Without LC_ALL only LC_MESSAGES is added") + func untranslatedMessagesWithoutLcAll() { + let environment = NativeDumpService.untranslatedMessagesEnvironment(["LANG": "ko_KR.UTF-8"]) + #expect(environment == ["LANG": "ko_KR.UTF-8", "LC_MESSAGES": "C"]) + } + @Test("empty host falls back to 127.0.0.1") func hostFallback() throws { let command = try NativeDumpService.buildCommand( @@ -191,7 +277,7 @@ struct NativeDumpServiceCommandTests { ) ) let allowed: Set = [ - "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", + "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_CTYPE", "LC_MESSAGES", "PGPASSWORD", "PGSSLMODE" ] let unexpected = Set(command.environment.keys).subtracting(allowed) @@ -257,17 +343,103 @@ private final class FakeDumpRunner: NativeDumpRunner, @unchecked Sendable { @Suite("NativeDumpService state machine", .serialized) @MainActor struct NativeDumpServiceStateMachineTests { - private func fakeJob() -> NativeDumpJob { + private func fakeJob(exitPolicy: NativeDumpExitPolicy = .zeroExitOnly) -> NativeDumpJob { .process( NativeDumpCommand( executable: URL(fileURLWithPath: "/usr/bin/true"), arguments: [], environment: [:], - stderrByteCap: 64_000 + stderrByteCap: 64_000, + exitPolicy: exitPolicy ) ) } + private static let skippedSettingsStderr = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + Command was: SET transaction_timeout = 0; + pg_restore: warning: errors ignored on restore: 1 + """ + + @Test("A restore whose only errors are unrecognized settings finishes and names them") + func restoreFinishesSkippingSettings() async throws { + let runner = FakeDumpRunner() + let service = service(kind: .restore, runner: runner) + let updates = service.stateUpdates() + + try service.run( + job: fakeJob(exitPolicy: .toleratesUnrecognizedSessionSettings), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-skipped.dump") + ) + runner.finish(.init(exitCode: 1, stderr: Self.skippedSettingsStderr, wasCancelled: false)) + let finalState = try await firstMatching(updates) { + switch $0 { + case .finished, .failed: return true + default: return false + } + } + + guard case .finished(let db, _, _, let skipped) = finalState else { + Issue.record("expected finished, got \(finalState)") + return + } + #expect(db == "sales") + #expect(skipped == ["transaction_timeout"]) + } + + @Test("The same output under the strict policy is still a failure") + func strictPolicyStillFails() async throws { + let runner = FakeDumpRunner() + let service = service(kind: .restore, runner: runner) + let updates = service.stateUpdates() + + try service.run( + job: fakeJob(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-strict.dump") + ) + runner.finish(.init(exitCode: 1, stderr: Self.skippedSettingsStderr, wasCancelled: false)) + let finalState = try await firstMatching(updates) { + switch $0 { + case .finished, .failed: return true + default: return false + } + } + + guard case .failed(let message, let targetMayBeModified) = finalState else { + Issue.record("expected failed, got \(finalState)") + return + } + #expect(message == Self.skippedSettingsStderr) + #expect(targetMayBeModified) + } + + @Test("A real error under the tolerant policy is still a failure") + func tolerantPolicyRealError() async throws { + let runner = FakeDumpRunner() + let service = service(kind: .restore, runner: runner) + let updates = service.stateUpdates() + + try service.run( + job: fakeJob(exitPolicy: .toleratesUnrecognizedSessionSettings), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-real-error.dump") + ) + runner.finish(.init(exitCode: 1, stderr: "FATAL: connection refused", wasCancelled: false)) + let finalState = try await firstMatching(updates) { + switch $0 { + case .finished, .failed: return true + default: return false + } + } + + guard case .failed = finalState else { + Issue.record("expected failed, got \(finalState)") + return + } + } + private func service(kind: NativeDumpKind, runner: FakeDumpRunner) -> NativeDumpService { NativeDumpService(kind: kind, runnerFactory: { job in runner.startedJob = job @@ -299,7 +471,7 @@ struct NativeDumpServiceStateMachineTests { runner.finish(.init(exitCode: 0, stderr: "", wasCancelled: false)) let finalState = try await firstMatching(updates) { if case .finished = $0 { return true }; return false } - if case .finished(let db, _, _) = finalState { + if case .finished(let db, _, _, _) = finalState { #expect(db == "sales") } else { Issue.record("expected finished, got \(finalState)") diff --git a/TableProTests/Database/PostgresRestoreDiagnosticsTests.swift b/TableProTests/Database/PostgresRestoreDiagnosticsTests.swift new file mode 100644 index 0000000000..f3a2bace4b --- /dev/null +++ b/TableProTests/Database/PostgresRestoreDiagnosticsTests.swift @@ -0,0 +1,287 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("PostgresRestoreDiagnostics") +struct PostgresRestoreDiagnosticsTests { + private static let pgRestore17IntoServer92 = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "lock_timeout" + Command was: SET lock_timeout = 0; + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "idle_in_transaction_session_timeout" + Command was: SET idle_in_transaction_session_timeout = 0; + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + Command was: SET transaction_timeout = 0; + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "row_security" + Command was: SET row_security = off; + pg_restore: warning: errors ignored on restore: 4 + """ + + private static let pgRestore17IntoServer12 = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + Command was: SET transaction_timeout = 0; + pg_restore: warning: errors ignored on restore: 1 + """ + + private static let pgRestore17ArchiveFrom17IntoServer96 = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + Command was: SET transaction_timeout = 0; + pg_restore: error: could not set "default_table_access_method": ERROR: unrecognized configuration parameter "default_table_access_method" + pg_restore: warning: errors ignored on restore: 2 + """ + + private static let pgRestore17SequenceDumpIntoServer96 = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + Command was: SET transaction_timeout = 0; + pg_restore: error: could not set "default_table_access_method": ERROR: unrecognized configuration parameter "default_table_access_method" + pg_restore: error: could not execute query: ERROR: syntax error at or near "AS" + LINE 2: AS integer + ^ + Command was: CREATE SEQUENCE public.t_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + + pg_restore: error: could not execute query: ERROR: relation "public.t_id_seq" does not exist + Command was: ALTER SEQUENCE public.t_id_seq OWNED BY public.t.id; + + + pg_restore: error: could not execute query: ERROR: relation "public.t_id_seq" does not exist + Command was: ALTER TABLE ONLY public.t ALTER COLUMN id SET DEFAULT nextval('public.t_id_seq'::regclass); + + + pg_restore: error: could not execute query: ERROR: relation "public.t_id_seq" does not exist + LINE 1: SELECT pg_catalog.setval('public.t_id_seq', 3, true); + ^ + Command was: SELECT pg_catalog.setval('public.t_id_seq', 3, true); + + + pg_restore: warning: errors ignored on restore: 6 + """ + + private static let pgRestore12InitializingIntoServer93 = """ + pg_restore: while INITIALIZING: + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "idle_in_transaction_session_timeout" + Command was: SET idle_in_transaction_session_timeout = 0; + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "row_security" + Command was: SET row_security = off; + pg_restore: warning: errors ignored on restore: 2 + """ + + private static let pgRestore12ProcessingTocIntoServer96 = """ + pg_restore: while PROCESSING TOC: + pg_restore: from TOC entry 202; 1259 19993 TABLE t postgres + pg_restore: error: could not set default_table_access_method: ERROR: unrecognized configuration parameter "default_table_access_method" + pg_restore: warning: errors ignored on restore: 1 + """ + + private static let pgRestore12BothPhasesIntoServer93 = """ + pg_restore: while INITIALIZING: + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "idle_in_transaction_session_timeout" + Command was: SET idle_in_transaction_session_timeout = 0; + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "row_security" + Command was: SET row_security = off; + pg_restore: while PROCESSING TOC: + pg_restore: from TOC entry 202; 1259 19993 TABLE t postgres + pg_restore: error: could not set default_table_access_method: ERROR: unrecognized configuration parameter "default_table_access_method" + pg_restore: warning: errors ignored on restore: 3 + """ + + private static let pgRestore11IntoServer93 = """ + pg_restore: [archiver (db)] Error while INITIALIZING: + pg_restore: [archiver (db)] could not execute query: ERROR: unrecognized configuration parameter "idle_in_transaction_session_timeout" + Command was: SET idle_in_transaction_session_timeout = 0; + + pg_restore: [archiver (db)] could not execute query: ERROR: unrecognized configuration parameter "row_security" + Command was: SET row_security = off; + + WARNING: errors ignored on restore: 2 + """ + + private static let pgRestore17FrenchLocaleIntoServer12 = """ + pg_restore: erreur : could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + La commande était : SET transaction_timeout = 0; + pg_restore: attention : erreurs ignorées lors de la restauration : 1 + """ + + private static func skipped(_ stderr: String, exitCode: Int32 = 1) -> [String]? { + PostgresRestoreDiagnostics.skippedSessionSettings(exitCode: exitCode, stderr: stderr) + } + + @Test("A same-server round trip with pg_restore 17 on 9.2 skips four settings") + func roundTripOnServer92() { + #expect(Self.skipped(Self.pgRestore17IntoServer92) == [ + "lock_timeout", "idle_in_transaction_session_timeout", "transaction_timeout", "row_security" + ]) + } + + @Test("pg_restore 17 on a 12 server skips transaction_timeout alone") + func roundTripOnServer12() { + #expect(Self.skipped(Self.pgRestore17IntoServer12) == ["transaction_timeout"]) + } + + @Test("pg_restore 17's quoted could not set form counts as a skipped setting") + func quotedCouldNotSetForm() { + #expect(Self.skipped(Self.pgRestore17ArchiveFrom17IntoServer96) == [ + "transaction_timeout", "default_table_access_method" + ]) + } + + @Test("pg_restore 12 prefixes the preamble with a while INITIALIZING context line") + func pgRestore12InitializingContext() { + #expect(Self.skipped(Self.pgRestore12InitializingIntoServer93) == [ + "idle_in_transaction_session_timeout", "row_security" + ]) + } + + @Test("pg_restore 12 sets the table access method per TOC entry, unquoted") + func pgRestore12ProcessingTocContext() { + #expect(Self.skipped(Self.pgRestore12ProcessingTocIntoServer96) == ["default_table_access_method"]) + } + + @Test("pg_restore 12 with errors in both phases") + func pgRestore12BothPhases() { + #expect(Self.skipped(Self.pgRestore12BothPhasesIntoServer93) == [ + "idle_in_transaction_session_timeout", "row_security", "default_table_access_method" + ]) + } + + @Test("pg_restore 11 and older use another format and are not second-guessed") + func pgRestore11FailsSafe() { + #expect(Self.skipped(Self.pgRestore11IntoServer93) == nil) + } + + @Test("Translated client messages are not second-guessed") + func translatedClientMessages() { + #expect(Self.skipped(Self.pgRestore17FrenchLocaleIntoServer12) == nil) + } + + @Test("A real object error among skipped settings is still a failure") + func realErrorAmongSkippedSettings() { + #expect(Self.skipped(Self.pgRestore17SequenceDumpIntoServer96) == nil) + } + + @Test("Empty output with a failing exit is a failure") + func emptyStderr() { + #expect(Self.skipped("") == nil) + } + + @Test("Only exit code 1, the errors-ignored code, is ever tolerated") + func otherExitCodes() { + #expect(Self.skipped(Self.pgRestore17IntoServer12, exitCode: 0) == nil) + #expect(Self.skipped(Self.pgRestore17IntoServer12, exitCode: 2) == nil) + } + + @Test("A count that disagrees with the errors shown means output was lost") + func truncatedOutput() { + let lines = Self.pgRestore17IntoServer92.split(separator: "\n", omittingEmptySubsequences: false) + #expect(Self.skipped(lines.dropFirst(2).joined(separator: "\n")) == nil) + } + + @Test("A missing errors-ignored summary is a failure") + func missingSummary() { + let lines = Self.pgRestore17IntoServer12.split(separator: "\n", omittingEmptySubsequences: false) + #expect(Self.skipped(lines.dropLast().joined(separator: "\n")) == nil) + } + + @Test("Anything after the summary is a failure, context lines included") + func outputAfterSummary() { + #expect(Self.skipped(Self.pgRestore17IntoServer12 + "\npg_restore: error: could not execute query: ERROR: boom") == nil) + #expect(Self.skipped(Self.pgRestore17IntoServer12 + "\npg_restore: while INITIALIZING:") == nil) + } + + @Test("The same setting rejected twice is named once") + func duplicateSettingNamedOnce() { + let stderr = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + Command was: SET transaction_timeout = 0; + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "transaction_timeout" + Command was: SET transaction_timeout = 0; + pg_restore: warning: errors ignored on restore: 2 + """ + #expect(Self.skipped(stderr) == ["transaction_timeout"]) + } + + @Test("A rejected SET naming a different parameter than the error is a failure") + func mismatchedSetParameter() { + let stderr = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "row_security" + Command was: SET transaction_timeout = 0; + pg_restore: warning: errors ignored on restore: 1 + """ + #expect(Self.skipped(stderr) == nil) + } + + @Test("A could not set line naming a different parameter than the error is a failure") + func mismatchedCouldNotSetParameter() { + let quoted = """ + pg_restore: error: could not set "default_table_access_method": ERROR: unrecognized configuration parameter "row_security" + pg_restore: warning: errors ignored on restore: 1 + """ + let unquoted = """ + pg_restore: error: could not set default_table_access_method: ERROR: unrecognized configuration parameter "row_security" + pg_restore: warning: errors ignored on restore: 1 + """ + #expect(Self.skipped(quoted) == nil) + #expect(Self.skipped(unquoted) == nil) + } + + @Test("A command carrying a second statement is not a skipped setting") + func multiStatementCommand() { + let stderr = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "bogus" + Command was: SET bogus = 1; CREATE TABLE t(id int); + pg_restore: warning: errors ignored on restore: 1 + """ + #expect(Self.skipped(stderr) == nil) + } + + @Test("A quoted value is one value, and a quote that ends early is not") + func quotedValues() { + let quoted = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "bogus" + Command was: SET bogus = 'it''s fine'; + pg_restore: warning: errors ignored on restore: 1 + """ + let escaping = """ + pg_restore: error: could not execute query: ERROR: unrecognized configuration parameter "bogus" + Command was: SET bogus = 'a'; DROP TABLE t; SELECT 'b'; + pg_restore: warning: errors ignored on restore: 1 + """ + #expect(Self.skipped(quoted) == ["bogus"]) + #expect(Self.skipped(escaping) == nil) + } + + @Test("A SET rejected for a bad value rather than an unknown parameter is a failure") + func invalidValue() { + let stderr = """ + pg_restore: error: could not execute query: ERROR: invalid value for parameter "client_encoding": "LATIN9X" + Command was: SET client_encoding = 'LATIN9X'; + pg_restore: warning: errors ignored on restore: 1 + """ + #expect(Self.skipped(stderr) == nil) + } + + @Test("A connection failure is a failure") + func connectionFailure() { + let stderr = """ + pg_restore: error: connection to server at "127.0.0.1", port 5432 failed: Connection refused + \tIs the server running on that host and accepting TCP/IP connections? + """ + #expect(Self.skipped(stderr) == nil) + } + + @Test("A server that reports errors in another language is not second-guessed") + func localizedServerMessage() { + let stderr = """ + pg_restore: error: could not execute query: ERROR: paramètre de configuration « transaction_timeout » non reconnu + Command was: SET transaction_timeout = 0; + pg_restore: warning: errors ignored on restore: 1 + """ + #expect(Self.skipped(stderr) == nil) + } +} diff --git a/TableProTests/Helpers/FakeMSSQLPlugin.swift b/TableProTests/Helpers/FakeMSSQLPlugin.swift index 91e54aac83..022cecdc3a 100644 --- a/TableProTests/Helpers/FakeMSSQLPlugin.swift +++ b/TableProTests/Helpers/FakeMSSQLPlugin.swift @@ -53,8 +53,12 @@ final class FakeMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var currentSchema: String? { "dbo" } var parameterStyle: ParameterStyle { .questionMark } + /// The one fact a driver reports about a connection the server has closed under it. + var hasLostConnection = false + private(set) var disconnectCallCount = 0 + func connect() async throws {} - func disconnect() {} + func disconnect() { disconnectCallCount += 1 } func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) diff --git a/TableProTests/Helpers/TestFixtures.swift b/TableProTests/Helpers/TestFixtures.swift index 56423273f5..e286e915ec 100644 --- a/TableProTests/Helpers/TestFixtures.swift +++ b/TableProTests/Helpers/TestFixtures.swift @@ -72,7 +72,7 @@ enum TestFixtures { originalRow: [String?]? = nil ) -> RowChange { return RowChange( - rowIndex: row, + rowID: .existing(row), type: type, cellChanges: cells, originalRow: originalRow.map { row in row.map(PluginCellValue.fromOptional) } diff --git a/TableProTests/Models/Database/MaintenanceEligibilityTests.swift b/TableProTests/Models/Database/MaintenanceEligibilityTests.swift new file mode 100644 index 0000000000..bc220a2502 --- /dev/null +++ b/TableProTests/Models/Database/MaintenanceEligibilityTests.swift @@ -0,0 +1,96 @@ +// +// MaintenanceEligibilityTests.swift +// TableProTests +// +// The sidebar, the menu bar and the MCP tool all filter maintenance through one function. Before it +// existed each surface offered every operation the driver named on every row, so PostgreSQL VACUUM +// reached a view, where the server skips it with a WARNING and still answers the command tag VACUUM. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Maintenance eligibility") +struct MaintenanceEligibilityTests { + private func operation( + _ name: String, + kinds: Set, + scope: PluginMaintenanceScope = .object + ) -> PluginMaintenanceOperation { + PluginMaintenanceOperation(name: name, appliesTo: kinds, scope: scope, options: []) + } + + private var postgresLike: [PluginMaintenanceOperation] { + [ + operation("VACUUM", kinds: [.table, .partitionedTable, .materializedView], scope: .objectOrDatabase), + operation("ANALYZE", kinds: [.table, .partitionedTable, .materializedView, .foreignTable], scope: .objectOrDatabase), + operation("REINDEX", kinds: [.table, .partitionedTable, .materializedView], scope: .objectOrDatabase), + operation("CLUSTER", kinds: [.table, .materializedView]) + ] + } + + @Test("A view keeps only the operations its kind is named in") + func viewKeepsNothingItCannotRun() { + let offered = TableOperationEligibility.maintenanceOperations(postgresLike, for: .view).map(\.name) + + #expect(offered.isEmpty) + } + + @Test("A materialized view keeps every operation but the ones its kind is absent from") + func materializedViewKeepsMost() { + let offered = TableOperationEligibility.maintenanceOperations(postgresLike, for: .materializedView).map(\.name) + + #expect(offered == ["VACUUM", "ANALYZE", "REINDEX", "CLUSTER"]) + } + + /// Measured on PostgreSQL 17.11: `ALTER TABLE ... CLUSTER ON` is refused on a partitioned table, + /// so CLUSTER can never succeed there however the server answers the CLUSTER statement itself. + @Test("A partitioned table keeps everything except CLUSTER") + func partitionedTableDropsCluster() { + let offered = TableOperationEligibility.maintenanceOperations(postgresLike, for: .partitionedTable).map(\.name) + + #expect(offered == ["VACUUM", "ANALYZE", "REINDEX"]) + #expect(!offered.contains("CLUSTER")) + } + + /// Measured on PostgreSQL 17.11: ANALYZE on a foreign table samples through the FDW and succeeds + /// with no warning, while VACUUM skips it. + @Test("A foreign table keeps ANALYZE alone") + func foreignTableKeepsAnalyze() { + let offered = TableOperationEligibility.maintenanceOperations(postgresLike, for: .foreignTable).map(\.name) + + #expect(offered == ["ANALYZE"]) + } + + /// A database-wide operation names no object, so the row it was reached from cannot disqualify it. + /// SQLite's VACUUM is reachable at all only because of this. + @Test("A database-wide operation survives every kind, including one it names nowhere") + func databaseWideOperationIsAlwaysKept() { + let wide = [operation("VACUUM", kinds: [], scope: .database)] + + for type in [TableInfo.TableType.table, .view, .materializedView, .foreignTable, .systemTable, .externalTable] { + #expect(TableOperationEligibility.maintenanceOperations(wide, for: type).map(\.name) == ["VACUUM"]) + } + } + + @Test("A row with no known type is treated as a table") + func unknownTypeFallsBackToTable() { + let offered = TableOperationEligibility.maintenanceOperations(postgresLike, for: nil).map(\.name) + + #expect(offered == ["VACUUM", "ANALYZE", "REINDEX", "CLUSTER"]) + } + + @Test("Every table-like kind maps to the driver's own spelling") + func kindsMapToDriverVocabulary() { + #expect(TableOperationEligibility.pluginKind(.table) == .table) + #expect(TableOperationEligibility.pluginKind(.partitionedTable) == .partitionedTable) + #expect(TableOperationEligibility.pluginKind(.view) == .view) + #expect(TableOperationEligibility.pluginKind(.materializedView) == .materializedView) + #expect(TableOperationEligibility.pluginKind(.foreignTable) == .foreignTable) + #expect(TableOperationEligibility.pluginKind(.systemTable) == .systemTable) + #expect(TableOperationEligibility.pluginKind(.externalTable) == .externalTable) + #expect(TableOperationEligibility.pluginKind(nil) == .table) + } +} diff --git a/TableProTests/Models/Database/StructureEditEligibilityTests.swift b/TableProTests/Models/Database/StructureEditEligibilityTests.swift new file mode 100644 index 0000000000..29362ead7f --- /dev/null +++ b/TableProTests/Models/Database/StructureEditEligibilityTests.swift @@ -0,0 +1,262 @@ +// +// StructureEditEligibilityTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +/// Every cell of the PostgreSQL matrix below was measured against a live PostgreSQL 17.11 server, +/// one statement per cell, rather than read out of the documentation. The pair that matters most is +/// `SET DEFAULT` and `CREATE INDEX`: a view takes the first and refuses the second, a materialized +/// view does the opposite, and that alone rules out sharing one row between them or collapsing the +/// object's kind to a single read-only Bool. (#2726) +@Suite("Structure Edit Eligibility") +struct StructureEditEligibilityTests { + private func allows( + _ operation: StructureEditOperation, + _ kind: TableInfo.TableType, + _ matrix: StructureObjectEditMatrix + ) -> Bool { + StructureEditEligibility.allows(operation, on: kind, matrix: matrix) + } + + // MARK: - The uncurated default + + @Test("An uncurated engine offers every edit on a table and a partitioned table") + func tablesOnlyAllowsEverythingOnTables() { + for kind in [TableInfo.TableType.table, .partitionedTable] { + for operation in StructureEditOperation.allCases { + #expect(allows(operation, kind, .tablesOnly), "\(kind.rawValue) refused \(operation)") + } + } + } + + @Test("An uncurated engine offers nothing on anything that is not a table") + func tablesOnlyRefusesEveryOtherKind() { + let kinds: [TableInfo.TableType] = [.view, .materializedView, .foreignTable, .systemTable, .externalTable] + for kind in kinds { + #expect(!StructureEditEligibility.allowsAnyEdit(on: kind, matrix: .tablesOnly), "\(kind.rawValue)") + for operation in StructureEditOperation.allCases { + #expect(!allows(operation, kind, .tablesOnly), "\(kind.rawValue) offered \(operation)") + } + } + } + + // MARK: - PostgreSQL, as measured + + @Test("A PostgreSQL view takes exactly the four edits the server accepts") + func postgresViewMatchesTheServer() { + let accepted: Set = [.renameColumn, .setDefault, .dropDefault, .commentOnColumn] + for operation in StructureEditOperation.allCases { + #expect( + allows(operation, .view, .postgreSQL) == accepted.contains(operation), + "view disagreed about \(operation)" + ) + } + } + + @Test("A PostgreSQL materialized view takes an index and refuses a default") + func postgresMaterializedViewMatchesTheServer() { + let accepted: Set = [.renameColumn, .commentOnColumn, .addIndex, .dropIndex] + for operation in StructureEditOperation.allCases { + #expect( + allows(operation, .materializedView, .postgreSQL) == accepted.contains(operation), + "materialized view disagreed about \(operation)" + ) + } + } + + /// The one cell that proves a view and a materialized view cannot share a matrix row. Measured: + /// `ALTER MATERIALIZED VIEW … SET DEFAULT` answers "ALTER action ALTER COLUMN ... SET DEFAULT + /// cannot be performed on relation mv", while the same statement on a view succeeds. + @Test("A view and a materialized view disagree about SET DEFAULT and CREATE INDEX") + func viewAndMaterializedViewDiffer() { + #expect(allows(.setDefault, .view, .postgreSQL)) + #expect(!allows(.setDefault, .materializedView, .postgreSQL)) + #expect(!allows(.addIndex, .view, .postgreSQL)) + #expect(allows(.addIndex, .materializedView, .postgreSQL)) + } + + @Test("A PostgreSQL foreign table takes every column change but no index and no key") + func postgresForeignTableMatchesTheServer() { + let accepted: Set = [ + .addColumn, .dropColumn, .renameColumn, .setNotNull, .dropNotNull, .setDefault, + .dropDefault, .changeColumnType, .addCheckConstraint, .dropCheckConstraint, .commentOnColumn + ] + for operation in StructureEditOperation.allCases { + #expect( + allows(operation, .foreignTable, .postgreSQL) == accepted.contains(operation), + "foreign table disagreed about \(operation)" + ) + } + } + + @Test("A PostgreSQL system table and external table take nothing") + func postgresReadOnlyKindsTakeNothing() { + for kind in [TableInfo.TableType.systemTable, .externalTable] { + #expect(!StructureEditEligibility.allowsAnyEdit(on: kind, matrix: .postgreSQL), "\(kind.rawValue)") + } + } + + @Test("A PostgreSQL table and partitioned table still take every edit") + func postgresTablesTakeEverything() { + for kind in [TableInfo.TableType.table, .partitionedTable] { + for operation in StructureEditOperation.allCases { + #expect(allows(operation, kind, .postgreSQL), "\(kind.rawValue) refused \(operation)") + } + } + } + + // MARK: - Per-field locking + + @Test("A view keeps Name, Default and Comment editable and locks the rest") + func viewEditableFields() { + let fields = StructureEditEligibility.editableFields(on: .view, matrix: .postgreSQL) + #expect(fields == [.name, .defaultValue, .comment]) + } + + @Test("A materialized view keeps only Name and Comment editable") + func materializedViewEditableFields() { + let fields = StructureEditEligibility.editableFields(on: .materializedView, matrix: .postgreSQL) + #expect(fields == [.name, .comment]) + } + + /// Primary Key, Auto Inc, On Update, Charset, Collation and Generated are all expressed by + /// rewriting the column definition, so they travel together behind `redefineColumn` rather than + /// each being gated through a proxy that means something else. + @Test("A foreign table locks the fields that need a column rewrite and opens the rest") + func foreignTableEditableFields() { + let fields = StructureEditEligibility.editableFields(on: .foreignTable, matrix: .postgreSQL) + #expect(fields == [.name, .type, .nullable, .defaultValue, .comment]) + #expect(!fields.contains(.primaryKey)) + #expect(!fields.contains(.autoIncrement)) + } + + @Test("A system table locks every field") + func systemTableLocksEverything() { + #expect(StructureEditEligibility.editableFields(on: .systemTable, matrix: .postgreSQL).isEmpty) + #expect(!StructureEditEligibility.allowsAnyEdit(on: .systemTable, matrix: .postgreSQL)) + } + + @Test("A table opens every field of the Columns grid") + func tableOpensEveryField() { + let fields = StructureEditEligibility.editableFields(on: .table, matrix: .postgreSQL) + #expect(fields == Set(StructureColumnField.allCases)) + } + + // MARK: - Reasons + + @Test("Every refusal carries a sentence, for every kind and every operation") + func everyRefusalExplainsItself() { + for kind in TableInfo.TableType.allCases { + for operation in StructureEditOperation.allCases { + let availability = StructureEditEligibility.resolve( + operation, + on: kind, + matrix: .postgreSQL, + engineAllows: true, + engineName: "PostgreSQL", + canEditSchema: true + ) + guard !availability.isAvailable else { continue } + #expect( + availability.unavailableReason?.isEmpty == false, + "\(kind.rawValue) refused \(operation) with no reason" + ) + } + } + } + + /// Each refusal names the kind the user is looking at, which is the half the old `isTable` Bool + /// could not supply: it made every non-table say "A view ...". + @Test("A refusal names the object's own kind") + func refusalNamesTheKind() { + let matview = StructureEditEligibility.refusalReason( + for: .setDefault, on: .materializedView, matrix: .postgreSQL + ) + #expect(matview?.contains("materialized view") == true) + + let foreign = StructureEditEligibility.refusalReason( + for: .addIndex, on: .foreignTable, matrix: .postgreSQL + ) + #expect(foreign?.contains("foreign table") == true) + } + + @Test("An accepted operation has no reason to give") + func acceptedOperationHasNoReason() { + #expect(StructureEditEligibility.refusalReason(for: .addIndex, on: .materializedView, matrix: .postgreSQL) == nil) + #expect(StructureEditEligibility.refusalReason(for: .addColumn, on: .table, matrix: .tablesOnly) == nil) + } + + // MARK: - Ordering + + @Test("An engine that cannot edit structure at all says that first, even on a table") + func readOnlyEngineOutranksTheKind() { + let availability = StructureEditEligibility.resolve( + .addColumn, + on: .table, + matrix: .postgreSQL, + engineAllows: true, + engineName: "Engine", + canEditSchema: false + ) + #expect(!availability.isAvailable) + #expect(availability.unavailableReason?.contains("Engine") == true) + } + + /// The kind allowing an edit is necessary, not sufficient. An engine with no `CREATE INDEX` + /// refuses it on a plain table too, and that refusal has to survive the new gate. + @Test("An engine flag still vetoes an operation the kind allows") + func engineFlagStillVetoes() { + let availability = StructureEditEligibility.resolve( + .addIndex, + on: .table, + matrix: .postgreSQL, + engineAllows: false, + engineName: "Engine", + canEditSchema: true + ) + #expect(!availability.isAvailable) + #expect(availability.unavailableReason?.contains("Engine") == true) + #expect(availability.unavailableReason?.contains("indexes") == true) + } + + /// An engine refusal worded once would put "cannot edit a table's structure" under a dimmed + /// **Add Index**, which tells the reader nothing they could act on. + @Test("An engine refusal names what it cannot do, not just that it cannot") + func engineRefusalNamesTheSubject() { + let subjects: [(StructureEditOperation, String)] = [ + (.addColumn, "columns"), + (.reorderColumns, "order"), + (.addIndex, "indexes"), + (.addCheckConstraint, "constraints") + ] + for (operation, expected) in subjects { + let availability = StructureEditEligibility.resolve( + operation, + on: .table, + matrix: .postgreSQL, + engineAllows: false, + engineName: "Engine", + canEditSchema: true + ) + #expect(availability.unavailableReason?.contains(expected) == true, "\(operation)") + } + } + + @Test("The kind outranks the engine flag, so the reason names the object rather than the engine") + func kindOutranksTheEngineFlag() { + let availability = StructureEditEligibility.resolve( + .addColumn, + on: .view, + matrix: .postgreSQL, + engineAllows: true, + engineName: "PostgreSQL", + canEditSchema: true + ) + #expect(availability.unavailableReason?.contains("view") == true) + } +} diff --git a/TableProTests/Models/DatabaseObjectToolsTests.swift b/TableProTests/Models/DatabaseObjectToolsTests.swift new file mode 100644 index 0000000000..d8c3fd816d --- /dev/null +++ b/TableProTests/Models/DatabaseObjectToolsTests.swift @@ -0,0 +1,217 @@ +// +// DatabaseObjectToolsTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Per-object command eligibility") +struct DatabaseObjectToolEligibilityTests { + private let support = DatabaseObjectToolEligibility.Support( + canRefreshMaterializedViews: true, + commentableTypes: [.table, .partitionedTable, .view, .materializedView, .foreignTable] + ) + + @Test("A view's source is its definition, so only the view kinds show DDL") + func ddlIsViewOnly() { + #expect(DatabaseObjectToolEligibility.canShowDDL(.view)) + #expect(DatabaseObjectToolEligibility.canShowDDL(.materializedView)) + for type in [TableInfo.TableType.table, .partitionedTable, .foreignTable, .systemTable, .externalTable] { + #expect(!DatabaseObjectToolEligibility.canShowDDL(type)) + } + #expect(!DatabaseObjectToolEligibility.canShowDDL(nil)) + } + + @Test("Refresh needs a materialized view, a driver statement and write access") + func refreshRequirements() { + #expect(DatabaseObjectToolEligibility.canRefresh(.materializedView, support: support, isReadOnly: false)) + #expect(!DatabaseObjectToolEligibility.canRefresh(.materializedView, support: support, isReadOnly: true)) + #expect(!DatabaseObjectToolEligibility.canRefresh(.view, support: support, isReadOnly: false)) + #expect(!DatabaseObjectToolEligibility.canRefresh(.materializedView, support: .none, isReadOnly: false)) + #expect(!DatabaseObjectToolEligibility.canRefresh(nil, support: support, isReadOnly: false)) + } + + @Test("Editing a comment follows the kinds the driver can comment on") + func commentRequirements() { + #expect(DatabaseObjectToolEligibility.canEditComment(.table, support: support, isReadOnly: false)) + #expect(DatabaseObjectToolEligibility.canEditComment(.materializedView, support: support, isReadOnly: false)) + #expect(!DatabaseObjectToolEligibility.canEditComment(.externalTable, support: support, isReadOnly: false)) + #expect(!DatabaseObjectToolEligibility.canEditComment(.table, support: support, isReadOnly: true)) + #expect(!DatabaseObjectToolEligibility.canEditComment(.table, support: .none, isReadOnly: false)) + } +} + +@Suite("Materialized view refresh prompt") +struct MaterializedViewRefreshPromptTests { + private func prompt( + _ availability: PluginConcurrentRefreshAvailability?, + checkFailed: Bool = false + ) -> MaterializedViewRefreshPrompt { + MaterializedViewRefreshPrompt( + qualifiedName: "sales.mv", + availability: availability, + availabilityCheckFailed: checkFailed + ) + } + + @Test("The prompt names the view it is about") + func promptNamesTheView() { + #expect(prompt(.available).messageText.contains("sales.mv")) + #expect(prompt(.available).confirmButtonTitle == String(localized: "Refresh")) + } + + /// The informative text has to warn about the lock, because a plain refresh holds an exclusive + /// lock on the view for its whole duration. + @Test("The prompt says a plain refresh blocks readers") + func promptWarnsAboutReaders() { + #expect(prompt(.available).informativeText.lowercased().contains("other sessions")) + } + + @Test("The concurrent option is enabled only for a view the server would accept") + func concurrentOptionEnablement() { + #expect(prompt(.available).isConcurrentOptionEnabled) + #expect(!prompt(.requiresUniqueIndex).isConcurrentOptionEnabled) + #expect(!prompt(.requiresPopulatedView).isConcurrentOptionEnabled) + #expect(!prompt(nil).isConcurrentOptionEnabled) + } + + /// An engine with no concurrent refresh shows no option at all. A check that failed shows it + /// disabled with its reason, rather than implying the engine lacks it. + @Test("The option is absent for an engine without one and present when the check failed") + func optionVisibility() { + #expect(prompt(.available).showsConcurrentOption) + #expect(prompt(.requiresUniqueIndex).showsConcurrentOption) + #expect(!prompt(nil).showsConcurrentOption) + #expect(prompt(nil, checkFailed: true).showsConcurrentOption) + #expect(!prompt(nil, checkFailed: true).isConcurrentOptionEnabled) + } + + @Test("Each unavailable reason explains itself") + func reasonsAreExplained() { + #expect(prompt(.requiresUniqueIndex).concurrentOptionDescription.contains("unique index")) + #expect(prompt(.requiresPopulatedView).concurrentOptionDescription.contains("rows")) + #expect(!prompt(nil, checkFailed: true).concurrentOptionDescription.isEmpty) + #expect(prompt(nil).concurrentOptionDescription.isEmpty) + } + + /// A checkbox left on by a previous state cannot ask for a refresh the server has already said + /// it would refuse. + @Test("A checked box is honoured only while the option is enabled") + func checkboxIsGated() { + #expect(prompt(.available).refreshesConcurrently(checkboxIsOn: true)) + #expect(!prompt(.available).refreshesConcurrently(checkboxIsOn: false)) + #expect(!prompt(.requiresUniqueIndex).refreshesConcurrently(checkboxIsOn: true)) + #expect(!prompt(nil, checkFailed: true).refreshesConcurrently(checkboxIsOn: true)) + } +} + +@Suite("Object comment draft") +struct ObjectCommentDraftTests { + @Test("A draft starts from the stored comment") + func startsFromStoredComment() { + let draft = ObjectCommentDraft(original: "daily totals") + + #expect(draft.text == "daily totals") + #expect(!draft.hasChanges) + #expect(draft.commentToSave == "daily totals") + #expect(!draft.removesComment) + } + + @Test("An object with no comment starts empty and has nothing to save") + func startsEmptyWithoutComment() { + let draft = ObjectCommentDraft(original: nil) + + #expect(draft.text.isEmpty) + #expect(!draft.hasChanges) + #expect(draft.commentToSave == nil) + #expect(!draft.removesComment) + } + + /// Clearing the field means removing the comment, which is the same thing PostgreSQL does with + /// an empty string. + @Test("Emptying the field removes the comment", arguments: ["", " ", "\n\t"]) + func emptyingRemovesTheComment(text: String) { + var draft = ObjectCommentDraft(original: "daily totals") + draft.text = text + + #expect(draft.commentToSave == nil) + #expect(draft.hasChanges) + #expect(draft.removesComment) + } + + @Test("Whitespace around a stored comment does not count as a change") + func whitespaceOnlyOriginalIsNoComment() { + let draft = ObjectCommentDraft(original: " ") + + #expect(draft.commentToSave == nil) + #expect(!draft.hasChanges) + } + + @Test("Editing the text is a change, and multiple lines are kept") + func editingIsAChange() { + var draft = ObjectCommentDraft(original: "one") + draft.text = "one\ntwo" + + #expect(draft.hasChanges) + #expect(draft.commentToSave == "one\ntwo") + #expect(!draft.removesComment) + } +} + +@MainActor +@Suite("Object source refs for views") +struct DatabaseObjectRefViewKindTests { + private func table(_ name: String, type: TableInfo.TableType) -> TableInfo { + TableInfo(name: name, type: type, rowCount: nil, schema: "sales") + } + + @Test("A view and a materialized view open as object source, other kinds do not") + func kindFromTableType() { + #expect(DatabaseObjectKind(tableType: .view) == .view) + #expect(DatabaseObjectKind(tableType: .materializedView) == .materializedView) + for type in [TableInfo.TableType.table, .partitionedTable, .foreignTable, .systemTable, .externalTable] { + #expect(DatabaseObjectKind(tableType: type) == nil) + } + } + + @Test("A ref built from a listing row carries the object's own database and schema") + func refCarriesScope() { + let ref = DatabaseObjectRef(relation: table("mv", type: .materializedView), database: "app", schema: "sales") + + #expect(ref?.kind == .materializedView) + #expect(ref?.database == "app") + #expect(ref?.schema == "sales") + #expect(ref?.qualifiedName == "sales.mv") + #expect(ref?.displayIdentity == "sales.mv") + #expect(DatabaseObjectRef(relation: table("users", type: .table), database: "app", schema: "sales") == nil) + } + + @Test("The view kinds map to their sidebar kind and survive a round trip") + func kindsRoundTrip() throws { + #expect(DatabaseObjectKind.view.sidebarObjectKind == .view) + #expect(DatabaseObjectKind.materializedView.sidebarObjectKind == .materializedView) + + for kind in [DatabaseObjectKind.view, .materializedView] { + let ref = DatabaseObjectRef(kind: kind, name: "mv", database: "app", schema: "sales") + let decoded = try JSONDecoder().decode( + DatabaseObjectRef.self, + from: try JSONEncoder().encode(ref) + ) + #expect(decoded == ref) + } + } + + @Test("The tab title names the kind") + func tabTitleNamesTheKind() { + let view = DatabaseObjectRef(kind: .view, name: "vw", database: "app", schema: "sales") + let matview = DatabaseObjectRef(kind: .materializedView, name: "mv", database: "app", schema: "sales") + + #expect(QueryTabManager.objectSourceTitle(for: view).contains("sales.vw")) + #expect(QueryTabManager.objectSourceTitle(for: matview).contains("sales.mv")) + #expect(QueryTabManager.objectSourceTitle(for: view) != QueryTabManager.objectSourceTitle(for: matview)) + } +} diff --git a/TableProTests/Models/DatabaseTypeOceanBaseTests.swift b/TableProTests/Models/DatabaseTypeOceanBaseTests.swift new file mode 100644 index 0000000000..dcee5681ee --- /dev/null +++ b/TableProTests/Models/DatabaseTypeOceanBaseTests.swift @@ -0,0 +1,50 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseType OceanBase") +struct DatabaseTypeOceanBaseTests { + @Test("rawValue is OceanBase") + func rawValue() { + #expect(DatabaseType.oceanbase.rawValue == "OceanBase") + } + + @Test("defaultPort is 2881") + func defaultPort() { + #expect(DatabaseType.oceanbase.defaultPort == 2_881) + } + + @Test("iconName is oceanbase-icon") + func iconName() { + #expect(DatabaseType.oceanbase.iconName == "oceanbase-icon") + } + + @Test("pluginTypeId resolves to MySQL") + func pluginTypeIdResolvesToMySQL() { + #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") + } + + @Test("triggers browse without trigger editing") + func triggerFlags() { + #expect(DatabaseType.oceanbase.supportsTriggers == true) + #expect(DatabaseType.oceanbase.supportsTriggerEditing == false) + } + + @Test("routines are offered") + func routines() { + #expect(DatabaseType.oceanbase.supportsRoutines == true) + } + + @Test("allKnownTypes contains oceanbase") + func allKnownTypesContainsOceanBase() { + #expect(DatabaseType.allKnownTypes.contains(.oceanbase)) + } + + @Test("Codable round-trips through rawValue") + func codableRoundTrip() throws { + let encoded = try JSONEncoder().encode(DatabaseType.oceanbase) + let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) + #expect(decoded == DatabaseType.oceanbase) + } +} diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 1e7a3a1e42..ba749d08d6 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -57,6 +57,7 @@ struct DatabaseTypeTests { (DatabaseType.mariadb, "MariaDB"), (DatabaseType.tidb, "TiDB"), (DatabaseType.databend, "Databend"), + (DatabaseType.oceanbase, "OceanBase"), (DatabaseType.postgresql, "PostgreSQL"), (DatabaseType.sqlite, "SQLite"), (DatabaseType.mongodb, "MongoDB"), @@ -119,6 +120,11 @@ struct DatabaseTypeTests { #expect(DatabaseType.databend.pluginTypeId == "MySQL") } + @Test("OceanBase pluginTypeId maps to MySQL plugin") + func testOceanBasePluginTypeId() { + #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") + } + @Test("Redshift pluginTypeId maps to PostgreSQL plugin") func testRedshiftPluginTypeId() { #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") @@ -156,7 +162,8 @@ struct DatabaseTypeTests { DatabaseType.mysql, DatabaseType.mariadb, DatabaseType.tidb, - DatabaseType.databend + DatabaseType.databend, + DatabaseType.oceanbase ]) func testMariaDBClientEnginesDefaultSSLPreferred(type: DatabaseType) { #expect(type.defaultSSLMode == .preferred) @@ -194,6 +201,7 @@ struct DatabaseTypeTests { DatabaseType.mariadb, DatabaseType.tidb, DatabaseType.databend, + DatabaseType.oceanbase, DatabaseType.mssql ]) func testOpportunisticTLSSupported(type: DatabaseType) { diff --git a/TableProTests/Models/Query/DeltaTests.swift b/TableProTests/Models/Query/DeltaTests.swift index 79fd0a6172..d2cbc0318b 100644 --- a/TableProTests/Models/Query/DeltaTests.swift +++ b/TableProTests/Models/Query/DeltaTests.swift @@ -75,4 +75,21 @@ struct DeltaTests { #expect(inserted != removed) #expect(Delta.columnsReplaced != Delta.fullReplace) } + + @Test("Rows arriving, leaving or being replaced change the row set") + func structuralDeltasChangeRowSet() { + #expect(Delta.rowsInserted(IndexSet(integer: 3)).changesRowSet) + #expect(Delta.rowsRemoved(IndexSet([1, 4])).changesRowSet) + #expect(Delta.fullReplace.changesRowSet) + } + + @Test("Cell edits, metadata and empty row deltas keep the row set") + func nonStructuralDeltasKeepRowSet() { + #expect(!Delta.cellChanged(row: 0, column: 1).changesRowSet) + #expect(!Delta.cellsChanged([CellPosition(row: 2, column: 0)]).changesRowSet) + #expect(!Delta.none.changesRowSet) + #expect(!Delta.columnsReplaced.changesRowSet) + #expect(!Delta.rowsInserted(IndexSet()).changesRowSet) + #expect(!Delta.rowsRemoved(IndexSet()).changesRowSet) + } } diff --git a/TableProTests/Models/Query/TabObjectKindTests.swift b/TableProTests/Models/Query/TabObjectKindTests.swift new file mode 100644 index 0000000000..e00329ec20 --- /dev/null +++ b/TableProTests/Models/Query/TabObjectKindTests.swift @@ -0,0 +1,159 @@ +// +// TabObjectKindTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +/// A tab used to carry one `isView` Bool, derived from `allowsRowEditing`, which is deliberately true +/// for a materialized view because a matview does hold rows. So a matview reached the Structure tab as +/// a table and was offered column, index and constraint edits PostgreSQL always refuses. The kind now +/// travels beside the Bool rather than replacing it: they answer different questions. (#2726) +@Suite("Tab Object Kind") +@MainActor +struct TabObjectKindTests { + private func tableTab() -> QueryTab { + QueryTab(id: UUID(), title: "mv_sales", query: "SELECT 1", tabType: .table, tableName: "mv_sales") + } + + @Test("A materialized view keeps its kind and leaves row editing alone") + func materializedViewKeepsItsKind() throws { + let manager = QueryTabManager() + try manager.addTableTab( + tableName: "mv_sales", + databaseType: .postgresql, + databaseName: "shop", + isView: false, + objectType: .materializedView + ) + + let tab = try #require(manager.selectedTab) + #expect(tab.tableContext.objectType == .materializedView) + #expect(tab.tableContext.isView == false) + #expect(tab.tableContext.resolvedObjectKind() == .materializedView) + } + + @Test("Retargeting a tab writes the new object's kind over the old one") + func retargetReplacesTheKind() throws { + let manager = QueryTabManager() + try manager.addTableTab( + tableName: "orders", databaseType: .postgresql, databaseName: "shop", objectType: .table + ) + try manager.replaceTabContent( + tableName: "v_orders", + databaseType: .postgresql, + isView: true, + objectType: .view, + databaseName: "shop" + ) + + let tab = try #require(manager.selectedTab) + #expect(tab.tableContext.objectType == .view) + #expect(tab.tableContext.resolvedObjectKind() == .view) + } + + // MARK: - Persistence + + @Test("The kind round-trips through the persisted tab") + func kindRoundTrips() { + var tab = tableTab() + tab.tableContext.objectType = .materializedView + + let persisted = tab.toPersistedTab() + #expect(persisted.objectTypeRawValue == "MATERIALIZED VIEW") + #expect(QueryTab(from: persisted, defaultPageSize: 1_000).tableContext.objectType == .materializedView) + } + + /// A tab saved before this shipped has no key at all. It must still decode, and it must fall back + /// to what the Bool beside it can still say, because the alternative is a thrown error that takes + /// every tab in the aggregate with it. + /// + /// The JSON is built by dropping the key from a real encode rather than typed out by hand, so the + /// test cannot pass or fail on a spelling the encoder does not use: `TabType` has no raw value, so + /// it encodes as `{"table": {}}` rather than `"table"`. + @Test("A file written before the kind existed decodes, and falls back to the Bool") + func missingKeyDecodesToTheFallback() throws { + let persisted = try decodePersisted(droppingKindFrom: tableTab()) + #expect(persisted.objectTypeRawValue == nil) + + let restored = QueryTab(from: persisted, defaultPageSize: 1_000) + #expect(restored.tableContext.objectType == nil) + #expect(restored.tableContext.resolvedObjectKind() == .table) + } + + @Test("A view saved before the kind existed falls back to a view, not a table") + func missingKeyOnAViewFallsBackToView() throws { + var tab = tableTab() + tab.tableContext.isView = true + tab.tableContext.objectType = .view + + let persisted = try decodePersisted(droppingKindFrom: tab) + #expect(persisted.objectTypeRawValue == nil) + #expect(QueryTab(from: persisted, defaultPageSize: 1_000).tableContext.resolvedObjectKind() == .view) + } + + /// A raw String rather than the enum, so a spelling a newer build invents is dropped instead of + /// throwing. A tab that comes back gated as a table is a smaller failure than a session that does + /// not come back. + @Test("A kind this build does not know decodes to nil rather than throwing") + func unknownKindDecodesToNil() throws { + var tab = tableTab() + tab.tableContext.objectType = .materializedView + + var object = try jsonObject(of: tab.toPersistedTab()) + object["objectTypeRawValue"] = "GRAPH" + let persisted = try JSONDecoder().decode( + PersistedTab.self, from: try JSONSerialization.data(withJSONObject: object) + ) + + #expect(persisted.objectTypeRawValue == "GRAPH") + #expect(QueryTab(from: persisted, defaultPageSize: 1_000).tableContext.objectType == nil) + } + + private func jsonObject(of persisted: PersistedTab) throws -> [String: Any] { + let data = try JSONEncoder().encode(persisted) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func decodePersisted(droppingKindFrom tab: QueryTab) throws -> PersistedTab { + var object = try jsonObject(of: tab.toPersistedTab()) + object.removeValue(forKey: "objectTypeRawValue") + return try JSONDecoder().decode( + PersistedTab.self, from: try JSONSerialization.data(withJSONObject: object) + ) + } + + // MARK: - Window payload + + @Test("The kind survives a trip through a window-tab payload") + func payloadCarriesTheKind() throws { + var tab = tableTab() + tab.tableContext.objectType = .foreignTable + + let payload = EditorTabPayload(from: tab, connectionId: UUID()) + #expect(payload.objectType == .foreignTable) + + let decoded = try JSONDecoder().decode( + EditorTabPayload.self, from: try JSONEncoder().encode(payload) + ) + #expect(decoded.objectType == .foreignTable) + } + + @Test("A payload with no kind key decodes to nil") + func payloadWithoutTheKindDecodes() throws { + var tab = tableTab() + tab.tableContext.objectType = .materializedView + + let data = try JSONEncoder().encode(EditorTabPayload(from: tab, connectionId: UUID())) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "objectType") + + let payload = try JSONDecoder().decode( + EditorTabPayload.self, from: try JSONSerialization.data(withJSONObject: object) + ) + #expect(payload.objectType == nil) + } +} diff --git a/TableProTests/Models/Query/TabSessionRegistryTests.swift b/TableProTests/Models/Query/TabSessionRegistryTests.swift index 5c3182e465..f1cd47c445 100644 --- a/TableProTests/Models/Query/TabSessionRegistryTests.swift +++ b/TableProTests/Models/Query/TabSessionRegistryTests.swift @@ -119,12 +119,96 @@ struct TabSessionRegistryTests { let before = session.dataRevision registry.updateTableRows(for: session.id) { rows in - rows.rows.append(Row(id: .existing(1), values: [.text("b")])) + rows.appendPage([[.text("b")]], startingAt: 1) } #expect(session.dataRevision > before) } + // MARK: - rowSetRevision + + @Test("A cell edit moves dataRevision and leaves rowSetRevision alone") + func cellEditKeepsRowSetRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + registry.setTableRows(makeRows(["a", "b"]), for: session.id) + let dataBefore = session.dataRevision + let rowSetBefore = session.rowSetRevision + + let delta = registry.updateTableRows(for: session.id) { rows in + rows.edit(row: 1, column: 0, value: .text("z")) + } + + #expect(delta == .cellChanged(row: 1, column: 0)) + #expect(session.dataRevision > dataBefore) + #expect(session.rowSetRevision == rowSetBefore) + } + + @Test("Inserting, removing or replacing rows moves rowSetRevision") + func structuralMutationsMoveRowSetRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + registry.setTableRows(makeRows(["a", "b"]), for: session.id) + + let beforeInsert = session.rowSetRevision + registry.updateTableRows(for: session.id) { rows in + rows.appendInsertedRow(values: [.text("c")]) + } + #expect(session.rowSetRevision > beforeInsert) + + let beforeRemove = session.rowSetRevision + registry.updateTableRows(for: session.id) { rows in + rows.remove(at: IndexSet(integer: 0)) + } + #expect(session.rowSetRevision > beforeRemove) + + let beforeReplace = session.rowSetRevision + registry.updateTableRows(for: session.id) { rows in + rows.replace(rows: [[.text("x")]]) + } + #expect(session.rowSetRevision > beforeReplace) + } + + @Test("Metadata that keeps every row, and a mutation that changes nothing, leave rowSetRevision alone") + func nonStructuralMutationsKeepRowSetRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + registry.setTableRows(makeRows(["a"]), for: session.id) + let before = session.rowSetRevision + + registry.updateTableRows(for: session.id) { rows in + rows.updateDisplayMetadata(columnComments: ["name": "the name"]) + } + registry.updateTableRows(for: session.id) { rows in + rows.remove(at: IndexSet(integer: 9)) + } + + #expect(session.rowSetRevision == before) + } + + @Test("Replacing, removing and evicting the buffer each move rowSetRevision") + func bufferReplacementMovesRowSetRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + + registry.setTableRows(makeRows(["a"]), for: session.id) + let afterSet = session.rowSetRevision + registry.evict(for: session.id) + let afterEvict = session.rowSetRevision + registry.setTableRows(makeRows(["b"]), for: session.id) + let afterSecondSet = session.rowSetRevision + registry.removeTableRows(for: session.id) + + #expect(afterSet > 0) + #expect(afterEvict > afterSet) + #expect(afterSecondSet > afterEvict) + #expect(session.rowSetRevision > afterSecondSet) + } + @Test("removeTableRows bumps dataRevision") func removeTableRowsBumpsDataRevision() { let registry = TabSessionRegistry() diff --git a/TableProTests/Models/Query/TabSessionTests.swift b/TableProTests/Models/Query/TabSessionTests.swift index 9f5b752f61..c422bb6697 100644 --- a/TableProTests/Models/Query/TabSessionTests.swift +++ b/TableProTests/Models/Query/TabSessionTests.swift @@ -23,6 +23,7 @@ struct TabSessionTests { #expect(session.tableRows.columns.isEmpty) #expect(session.isEvicted == false) #expect(session.dataRevision == 0) + #expect(session.rowSetRevision == 0) } @Test("A session keeps the id it was created with") diff --git a/TableProTests/Models/ResultStatusModelTests.swift b/TableProTests/Models/ResultStatusModelTests.swift index b5d378e0d9..00d7005949 100644 --- a/TableProTests/Models/ResultStatusModelTests.swift +++ b/TableProTests/Models/ResultStatusModelTests.swift @@ -199,6 +199,21 @@ struct ResultStatusModelTests { #expect(!structure.controls.showsPagination) } + @Test("Highlight rules are offered only where the data grid draws the result") + func highlightRulesFollowTheDataGrid() { + let table = makeSnapshot(rowCount: 10) + #expect(model(table, viewMode: .data).controls.showsHighlightRules) + #expect(!model(table, viewMode: .json).controls.showsHighlightRules) + #expect(!model(table, viewMode: .chart).controls.showsHighlightRules) + #expect(!model(table, viewMode: .structure).controls.showsHighlightRules) + + let query = makeSnapshot(tabType: .query, rowCount: 3, hasTableName: false) + #expect(model(query, viewMode: .data).controls.showsHighlightRules) + + let noResult = makeSnapshot(tabType: .query, rowCount: 0, hasColumns: false, hasTableName: false) + #expect(!model(noResult, viewMode: .data).controls.showsHighlightRules) + } + @Test("A query tab never offers table-only controls") func queryTabHasNoTableControls() { var pagination = PaginationState(pageSize: 1_000) diff --git a/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift b/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift index dd16991a5f..13e72eee04 100644 --- a/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift +++ b/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift @@ -12,7 +12,7 @@ struct ColumnReorderPolicyTests { private func resolve( support: ColumnReorderSupport = .alter, isColumnsTab: Bool = true, - isTable: Bool = true, + kindRefusal: String? = nil, canEditSchema: Bool = true, hasStagedChanges: Bool = false, isRearranged: Bool = false @@ -21,7 +21,7 @@ struct ColumnReorderPolicyTests { support: support, engineName: "PostgreSQL", isColumnsTab: isColumnsTab, - isTable: isTable, + kindRefusal: kindRefusal, canEditSchema: canEditSchema, hasStagedChanges: hasStagedChanges, isRearranged: isRearranged @@ -76,11 +76,11 @@ struct ColumnReorderPolicyTests { /// withheld rather than acted on against the wrong column. /// Every mechanism emits table DDL, and the SQLite one looks its target up as a table, so a /// view drag would end in a statement error instead of an explanation. - @Test("A view is withheld, whatever the engine can do to a table") - func viewWithholdsTheDrag() { - let availability = resolve(isTable: false) + @Test("A refusing object kind is withheld, whatever the engine can do to a table") + func refusingKindWithholdsTheDrag() { + let availability = resolve(kindRefusal: "A view has no column order of its own to change.") #expect(!availability.isAvailable) - #expect(availability.unavailableReason != nil) + #expect(availability.unavailableReason == "A view has no column order of its own to change.") } @Test("A filtered or sorted column list withholds the drag") diff --git a/TableProTests/Models/Schema/ForeignKeyEditPolicyTests.swift b/TableProTests/Models/Schema/ForeignKeyEditPolicyTests.swift index f5a3f4b149..e24e2cd95f 100644 --- a/TableProTests/Models/Schema/ForeignKeyEditPolicyTests.swift +++ b/TableProTests/Models/Schema/ForeignKeyEditPolicyTests.swift @@ -11,13 +11,13 @@ import Testing struct ForeignKeyEditPolicyTests { private func resolve( _ support: ForeignKeyEditSupport, - isTable: Bool = true, + kindRefusal: String? = nil, canEditSchema: Bool = true ) -> ForeignKeyEditAvailability { ForeignKeyEditPolicy.resolve( support: support, engineName: "Engine", - isTable: isTable, + kindRefusal: kindRefusal, canEditSchema: canEditSchema ) } @@ -43,11 +43,14 @@ struct ForeignKeyEditPolicyTests { #expect(availability.unavailableReason?.contains("Engine") == true) } - @Test("A view has no foreign keys of its own") - func withholdsOnViews() { - let availability = resolve(.rebuild, isTable: false) + /// The reason comes from the caller's per-kind matrix, because only that knows whether the user + /// is looking at a view, a materialized view or a foreign table. PostgreSQL refuses + /// `ADD CONSTRAINT … FOREIGN KEY` on all three. + @Test("An object kind that refuses the edit is withheld with its own reason") + func withholdsOnRefusingKinds() { + let availability = resolve(.rebuild, kindRefusal: "A materialized view cannot have constraints.") #expect(!availability.isAvailable) - #expect(availability.unavailableReason != nil) + #expect(availability.unavailableReason == "A materialized view cannot have constraints.") } /// Checked before the engine's own capability so a read-only engine explains that rather than @@ -62,7 +65,9 @@ struct ForeignKeyEditPolicyTests { @Test("Every reason is a sentence, never an empty string") func alwaysExplainsItself() { let withheld = [ - resolve(.unsupported), resolve(.rebuild, isTable: false), resolve(.alter, canEditSchema: false) + resolve(.unsupported), + resolve(.rebuild, kindRefusal: "A view cannot have constraints."), + resolve(.alter, canEditSchema: false) ] for availability in withheld { #expect(availability.unavailableReason?.isEmpty == false) diff --git a/TableProTests/Plugins/ClickHouseParameterBindingTests.swift b/TableProTests/Plugins/ClickHouseParameterBindingTests.swift new file mode 100644 index 0000000000..6de5594233 --- /dev/null +++ b/TableProTests/Plugins/ClickHouseParameterBindingTests.swift @@ -0,0 +1,88 @@ +// +// ClickHouseParameterBindingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("ClickHouse Parameter Binding") +struct ClickHouseParameterBindingTests { + @Test("Text parameters become named HTTP substitutions") + func textParametersBecomeNamedSubstitutions() { + let bound = ClickHouseParameterBinding.bind( + query: "SELECT * FROM t WHERE a = ? AND b = ?", + parameters: [.text("one"), .text("two")] + ) + #expect(bound.query == "SELECT * FROM t WHERE a = {p1:String} AND b = {p2:String}") + #expect(bound.params["p1"] == "one") + #expect(bound.params["p2"] == "two") + } + + @Test("A null parameter keeps its slot with no value") + func nullParameterKeepsItsSlot() { + let bound = ClickHouseParameterBinding.bind( + query: "SELECT * FROM t WHERE a = ?", + parameters: [.null] + ) + #expect(bound.query == "SELECT * FROM t WHERE a = {p1:String}") + #expect(bound.params["p1"] == .some(nil)) + } + + @Test("A binary parameter is written into the statement as unhex, never as text") + func binaryParameterBecomesUnhex() { + let bound = ClickHouseParameterBinding.bind( + query: "ALTER TABLE t DELETE WHERE raw = ?", + parameters: [.bytes(Data([0xDE, 0xAD, 0xBE, 0xEF]))] + ) + #expect(bound.query == "ALTER TABLE t DELETE WHERE raw = unhex('DEADBEEF')") + #expect(bound.params.isEmpty) + } + + @Test("Named numbering stays contiguous when a binary parameter is inlined") + func namedNumberingSkipsInlinedBinary() { + let bound = ClickHouseParameterBinding.bind( + query: "ALTER TABLE t UPDATE txt = ? WHERE raw = ? AND id = ?", + parameters: [.text("new"), .bytes(Data([0x01, 0x02])), .text("7")] + ) + #expect(bound.query == "ALTER TABLE t UPDATE txt = {p1:String} WHERE raw = unhex('0102') AND id = {p2:String}") + #expect(bound.params["p1"] == "new") + #expect(bound.params["p2"] == "7") + #expect(bound.params.count == 2) + } + + @Test("An empty binary value is still a binary literal") + func emptyBinaryParameterIsALiteral() { + let bound = ClickHouseParameterBinding.bind( + query: "SELECT ?", + parameters: [.bytes(Data())] + ) + #expect(bound.query == "SELECT unhex('')") + } + + @Test("A question mark inside a string literal is not a placeholder") + func questionMarkInsideALiteralIsNotAPlaceholder() { + let bound = ClickHouseParameterBinding.bind( + query: "SELECT * FROM t WHERE a = 'why?' AND b = ?", + parameters: [.text("x")] + ) + #expect(bound.query == "SELECT * FROM t WHERE a = 'why?' AND b = {p1:String}") + #expect(bound.params["p1"] == "x") + } + + @Test("Extra placeholders past the parameter list are left alone") + func extraPlaceholdersAreLeftAlone() { + let bound = ClickHouseParameterBinding.bind( + query: "SELECT ?, ?", + parameters: [.text("only")] + ) + #expect(bound.query == "SELECT {p1:String}, ?") + } + + @Test("The hex a binary parameter produces cannot close the literal it sits in") + func hexLiteralIsAlphanumericOnly() { + let literal = ClickHouseParameterBinding.hexLiteral(Data([0x27, 0x5C, 0x00, 0xFF])) + #expect(literal == "unhex('275C00FF')") + } +} diff --git a/TableProTests/Plugins/ClickHouseResponseClassifierTests.swift b/TableProTests/Plugins/ClickHouseResponseClassifierTests.swift index 0aeabd04cd..5e8f9e25e8 100644 --- a/TableProTests/Plugins/ClickHouseResponseClassifierTests.swift +++ b/TableProTests/Plugins/ClickHouseResponseClassifierTests.swift @@ -99,6 +99,113 @@ struct ClickHouseResponseClassifierTests { #expect(outcome.rows == [[.text("a\tb\nc\\d")]]) } + // MARK: - The Full Escape Table ClickHouse Writes + + @Test("Every escape the server emits is decoded, not left as two characters") + func serverEscapeTableIsDecoded() { + let outcome = classify( + headers: matchingFormatHeaders, + bodyText: "cr\tquote\tbell\tff\tnul\nString\tString\tString\tString\tString\n" + + "a\\rb\ta\\'b\ta\\bb\ta\\fb\ta\\0b\n" + ) + #expect(outcome.rows == [[ + .text("a\rb"), + .text("a'b"), + .text("a\u{8}b"), + .text("a\u{C}b"), + .text("a\u{0}b") + ]]) + } + + @Test("A column type name carrying escaped quotes is reported unescaped") + func columnTypeNameIsUnescaped() { + let outcome = classify( + headers: matchingFormatHeaders, + bodyText: "e\nEnum8(\\'q\\' = 1)\nq\n" + ) + #expect(outcome.columnTypeNames == ["Enum8('q' = 1)"]) + } + + @Test("A column name carrying an escaped tab keeps the column count") + func columnNameWithEscapedTabIsOneColumn() { + let outcome = classify(headers: matchingFormatHeaders, bodyText: "a\\tb\nString\nv\n") + #expect(outcome.columns == ["a\tb"]) + #expect(outcome.rows == [[.text("v")]]) + } + + // MARK: - Binary Values + + @Test("A binary value stays bytes instead of being forced through a text decode") + func binaryValueStaysBytes() { + var body = Data("raw\nString\n".utf8) + body.append(contentsOf: [0xDE, 0xAD, 0xBE, 0xEF, 0x0A]) + let outcome = ClickHouseResponseClassifier.classify(headers: matchingFormatHeaders, body: body) + #expect(outcome.rows == [[.bytes(Data([0xDE, 0xAD, 0xBE, 0xEF]))]]) + } + + @Test("A binary value in one column never mojibakes a text value in another") + func binaryValueLeavesOtherColumnsIntact() { + var body = Data("raw\ttxt\nString\tString\n".utf8) + body.append(contentsOf: [0xDE, 0xAD, 0xBE, 0xEF, 0x09]) + body.append(contentsOf: Data("héllo\n".utf8)) + let outcome = ClickHouseResponseClassifier.classify(headers: matchingFormatHeaders, body: body) + #expect(outcome.rows == [[.bytes(Data([0xDE, 0xAD, 0xBE, 0xEF])), .text("héllo")]]) + } + + @Test("One undecodable value makes the whole column bytes, losslessly") + func oneBinaryValueDemotesItsWholeColumn() { + var body = Data("v\nString\n".utf8) + body.append(contentsOf: Data("héllo\n".utf8)) + body.append(contentsOf: [0xC3, 0x0A]) + let outcome = ClickHouseResponseClassifier.classify(headers: matchingFormatHeaders, body: body) + #expect(outcome.rows == [ + [.bytes(Data("héllo".utf8))], + [.bytes(Data([0xC3]))] + ]) + } + + @Test("A binary value does not demote a text column beside it") + func demotionIsPerColumn() { + var body = Data("a\tb\nString\tString\n".utf8) + body.append(contentsOf: [0xC3, 0x09]) + body.append(contentsOf: Data("keep\n".utf8)) + let outcome = ClickHouseResponseClassifier.classify(headers: matchingFormatHeaders, body: body) + #expect(outcome.rows == [[.bytes(Data([0xC3])), .text("keep")]]) + } + + @Test("A tab or newline inside binary data arrives escaped and never splits a field") + func escapedControlBytesInBinaryDoNotSplitFields() { + var body = Data("v\nString\n".utf8) + body.append(contentsOf: Array("\\t\\n".utf8)) + body.append(contentsOf: [0x0B, 0x41, 0x0A]) + let outcome = ClickHouseResponseClassifier.classify(headers: matchingFormatHeaders, body: body) + #expect(outcome.rows == [[.text("\t\n\u{B}A")]]) + } + + @Test("A body in another format that is not text is kept as bytes") + func nonTextRawBodyIsKeptAsBytes() { + let body = Data([0x00, 0x01, 0xFF, 0xFE]) + let outcome = ClickHouseResponseClassifier.classify(headers: ["X-ClickHouse-Format": "Native"], body: body) + #expect(outcome.rows == [[.bytes(body)]]) + } + + @Test("A body cut by the byte cap mid-character still reads as text") + func cappedBodyCutMidCharacterIsText() { + let cap = 1_048_576 + var body = Data(repeating: UInt8(ascii: "x"), count: cap - 1) + body.append(contentsOf: Data("é".utf8)) + let outcome = ClickHouseResponseClassifier.classify(headers: ["X-ClickHouse-Format": "Pretty"], body: body) + #expect(outcome.isTruncated) + #expect(outcome.rows[0][0].asText?.utf8.count == cap - 1) + } + + @Test("A body the cap did not cut is never trimmed to make it decode") + func uncutBodyIsNotTrimmed() { + let body = Data([0x61, 0x62, 0xC3]) + let outcome = ClickHouseResponseClassifier.classify(headers: ["X-ClickHouse-Format": "Native"], body: body) + #expect(outcome.rows == [[.bytes(body)]]) + } + @Test("Rows beyond the limit are dropped and marked truncated") func rowLimitTruncates() { let outcome = classify( diff --git a/TableProTests/Plugins/ClickHouseTabSeparatedRowDecoderTests.swift b/TableProTests/Plugins/ClickHouseTabSeparatedRowDecoderTests.swift new file mode 100644 index 0000000000..e911b7024f --- /dev/null +++ b/TableProTests/Plugins/ClickHouseTabSeparatedRowDecoderTests.swift @@ -0,0 +1,183 @@ +// +// ClickHouseTabSeparatedRowDecoderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("ClickHouse Tab Separated Row Decoder") +struct ClickHouseTabSeparatedRowDecoderTests { + private struct Decoded { + let header: ClickHouseTabSeparatedRowDecoder.Header? + let rows: [[PluginCellValue]] + } + + private static let header = Data("id\tlabel\tpayload\nUInt32\tString\tString\n".utf8) + + private func decodeAll(_ chunks: [Data]) -> Decoded { + var decoder = ClickHouseTabSeparatedRowDecoder() + var rows: [[PluginCellValue]] = [] + for chunk in chunks { + rows.append(contentsOf: decoder.consume(chunk)) + } + rows.append(contentsOf: decoder.finish()) + return Decoded(header: decoder.header, rows: rows) + } + + private func decodeAll(_ body: Data) -> Decoded { + decodeAll([body]) + } + + private func bytewise(_ body: Data) -> [[PluginCellValue]] { + decodeAll(body.map { Data([$0]) }).rows + } + + // MARK: - Header + + @Test("The first two lines are the column names and the column types") + func readsNamesAndTypes() { + let outcome = decodeAll(Self.header) + #expect(outcome.header?.columns == ["id", "label", "payload"]) + #expect(outcome.header?.columnTypeNames == ["UInt32", "String", "String"]) + #expect(outcome.rows.isEmpty) + } + + @Test("A body holding nothing reports no header") + func emptyBodyHasNoHeader() { + let outcome = decodeAll(Data()) + #expect(outcome.header == nil) + #expect(outcome.rows.isEmpty) + } + + @Test("A name that is not valid UTF-8 keeps the column rather than dropping it") + func headerReplacesUndecodableBytes() { + var body = Data("id\t".utf8) + body.append(contentsOf: [0xDE, 0xAD]) + body.append(contentsOf: Data("\nUInt32\tString\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.header?.columns.count == 2) + #expect(outcome.header?.columns.first == "id") + } + + // MARK: - Values + + @Test("A field that decodes as UTF-8 is text") + func decodesTextValues() { + var body = Self.header + body.append(contentsOf: Data("1\tünïcødé\thello\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows == [[.text("1"), .text("ünïcødé"), .text("hello")]]) + } + + @Test("A field that is not valid UTF-8 keeps its exact bytes") + func keepsUndecodableBytes() { + var body = Self.header + body.append(contentsOf: Data("1\tplain\t".utf8)) + body.append(contentsOf: [0xDE, 0xAD, 0xBE, 0xEF]) + body.append(contentsOf: Data("\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows.count == 1) + #expect(outcome.rows[0][2] == .bytes(Data([0xDE, 0xAD, 0xBE, 0xEF]))) + } + + @Test("A binary value in one row leaves a text value in the same column as text") + func decidesPerValueRatherThanPerColumn() { + var body = Self.header + body.append(contentsOf: Data("1\ta\t".utf8)) + body.append(contentsOf: [0xDE, 0xAD, 0xBE, 0xEF]) + body.append(contentsOf: Data("\n2\tb\thello world\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows.count == 2) + #expect(outcome.rows[0][2] == .bytes(Data([0xDE, 0xAD, 0xBE, 0xEF]))) + #expect(outcome.rows[1][2] == .text("hello world")) + } + + @Test("A lone backslash-N field is null") + func readsNullMarker() { + var body = Self.header + body.append(contentsOf: Data("1\t\\N\tvalue\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows == [[.text("1"), .null, .text("value")]]) + } + + @Test("An escaped backslash before N is the literal text, not null") + func escapedBackslashIsNotNull() { + var body = Self.header + body.append(contentsOf: Data("1\t\\\\N\tvalue\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows == [[.text("1"), .text("\\N"), .text("value")]]) + } + + @Test("Every escape ClickHouse writes comes back as its byte") + func unescapesEveryEscape() { + var body = Self.header + body.append(contentsOf: Data("1\ta\\tb\\nc\\rd\\0e\\be\\fg\\'h\\\\i\tvalue\n".utf8)) + let outcome = decodeAll(body) + let expected = "a\tb\nc\rd\u{0}e\u{8}e\u{C}g'h\\i" + #expect(outcome.rows.count == 1) + #expect(outcome.rows[0][1] == .text(expected)) + } + + @Test("Array, Map and Tuple arrive as the text ClickHouse writes for them") + func keepsCompoundValuesAsWritten() { + var body = Data("tags\tattrs\tpair\nArray(String)\tMap(String, String)\tTuple(UInt8, String)\n".utf8) + body.append(contentsOf: Data("['a','b']\t{'k':'v'}\t(7,'x')\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows == [[.text("['a','b']"), .text("{'k':'v'}"), .text("(7,'x')")]]) + } + + // MARK: - Chunk boundaries + + @Test("A row split across two chunks decodes once it is whole") + func joinsRowsAcrossChunks() { + var first = Self.header + first.append(contentsOf: Data("1\tlab".utf8)) + let second = Data("el\tvalue\n".utf8) + let outcome = decodeAll([first, second]) + #expect(outcome.rows == [[.text("1"), .text("label"), .text("value")]]) + } + + @Test("A chunk that cuts a multi-byte character in half does not corrupt it") + func joinsMultiByteCharactersAcrossChunks() { + var body = Self.header + body.append(contentsOf: Data("1\tünïcødé\tvalue\n".utf8)) + #expect(bytewise(body) == [[.text("1"), .text("ünïcødé"), .text("value")]]) + } + + @Test("Feeding one byte at a time gives the same rows as one chunk") + func chunkingDoesNotChangeTheResult() { + var body = Self.header + body.append(contentsOf: Data("1\ta\t".utf8)) + body.append(contentsOf: [0xDE, 0xAD, 0xBE, 0xEF]) + body.append(contentsOf: Data("\n2\tb\thello\n3\t\\N\t\\t\n".utf8)) + #expect(bytewise(body) == decodeAll(body).rows) + } + + // MARK: - Body edges + + @Test("A body that ends without its closing newline still yields its last row") + func yieldsUnterminatedFinalRow() { + var body = Self.header + body.append(contentsOf: Data("1\tlabel\tvalue".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows == [[.text("1"), .text("label"), .text("value")]]) + } + + @Test("A blank line between rows is not a row") + func skipsBlankLines() { + var body = Self.header + body.append(contentsOf: Data("1\ta\tb\n\n2\tc\td\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows.count == 2) + } + + @Test("A field holding nothing is empty text, never null") + func emptyFieldIsEmptyText() { + var body = Self.header + body.append(contentsOf: Data("1\t\tvalue\n".utf8)) + let outcome = decodeAll(body) + #expect(outcome.rows == [[.text("1"), .text(""), .text("value")]]) + } +} diff --git a/TableProTests/Plugins/CockroachRelationSQLTests.swift b/TableProTests/Plugins/CockroachRelationSQLTests.swift new file mode 100644 index 0000000000..0b8a6ff9be --- /dev/null +++ b/TableProTests/Plugins/CockroachRelationSQLTests.swift @@ -0,0 +1,74 @@ +// +// CockroachRelationSQLTests.swift +// TableProTests +// +// Tests for the CockroachDB SHOW statement builders (compiled via the plugin source list). +// Regression cover for metadata reads that took a schema argument and queried the session's +// current schema instead, so an export or compare of an object outside the connection's own +// schema read a same-named object in that schema or failed to find one at all. +// + +import Foundation +import Testing + +@Suite("CockroachRelationSQL") +struct CockroachRelationSQLTests { + @Test("SHOW CREATE TABLE names the requested schema") + func showCreateTableNamesRequestedSchema() { + let statement = CockroachRelationSQL.showCreateTable(table: "orders", schema: "analytics") + #expect(statement == "SHOW CREATE TABLE \"analytics\".\"orders\"") + } + + @Test("SHOW CREATE VIEW names the requested schema") + func showCreateViewNamesRequestedSchema() { + let statement = CockroachRelationSQL.showCreateView(view: "daily_rides", schema: "analytics") + #expect(statement == "SHOW CREATE VIEW \"analytics\".\"daily_rides\"") + } + + @Test("SHOW INDEXES names the requested schema") + func showIndexesNamesRequestedSchema() { + let statement = CockroachRelationSQL.showIndexes(table: "orders", schema: "analytics") + #expect(statement == "SHOW INDEXES FROM \"analytics\".\"orders\"") + } + + @Test("no builder substitutes the public schema", arguments: ["public", "analytics", "s2", "Mixed Case"]) + func everyBuilderCarriesTheSchemaThrough(schema: String) { + let statements = [ + CockroachRelationSQL.showCreateTable(table: "orders", schema: schema), + CockroachRelationSQL.showCreateView(view: "orders", schema: schema), + CockroachRelationSQL.showIndexes(table: "orders", schema: schema), + ] + for statement in statements { + #expect(statement.contains("\"\(schema)\".\"orders\"")) + if schema != "public" { + #expect(!statement.contains("\"public\".")) + } + } + } + + @Test("an embedded double quote is doubled in both parts") + func embeddedQuoteIsDoubled() { + let statement = CockroachRelationSQL.showCreateTable(table: "we\"ird", schema: "my\"schema") + #expect(statement == "SHOW CREATE TABLE \"my\"\"schema\".\"we\"\"ird\"") + } + + @Test("the target is the shared qualified name") + func targetIsSharedQualifiedName() { + let target = PostgreSQLObjectQueries.qualifiedName(schema: "s", name: "t") + #expect(CockroachRelationSQL.showCreateTable(table: "t", schema: "s").hasSuffix(target)) + #expect(CockroachRelationSQL.showCreateView(view: "t", schema: "s").hasSuffix(target)) + #expect(CockroachRelationSQL.showIndexes(table: "t", schema: "s").hasSuffix(target)) + } + + @Test("the name carries no database component") + func nameCarriesNoDatabaseComponent() { + let statements = [ + CockroachRelationSQL.showCreateTable(table: "orders", schema: "analytics"), + CockroachRelationSQL.showCreateView(view: "orders", schema: "analytics"), + CockroachRelationSQL.showIndexes(table: "orders", schema: "analytics"), + ] + for statement in statements { + #expect(statement.components(separatedBy: ".").count == 2) + } + } +} diff --git a/TableProTests/Plugins/LibPQCellDecodingTests.swift b/TableProTests/Plugins/LibPQCellDecodingTests.swift new file mode 100644 index 0000000000..11a9ea7337 --- /dev/null +++ b/TableProTests/Plugins/LibPQCellDecodingTests.swift @@ -0,0 +1,61 @@ +// +// LibPQCellDecodingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("LibPQCellDecoding") +struct LibPQCellDecodingTests { + private static let textOid: UInt32 = 25 + private static let booleanOid: UInt32 = 16 + private static let byteaOid: UInt32 = 17 + + private func decode(_ bytes: [UInt8], oid: UInt32 = textOid) -> PluginCellValue { + bytes.withUnsafeBytes { LibPQCellDecoding.value(from: $0, oid: oid) } + } + + @Test("UTF-8 text decodes as written") + func utf8Text() { + #expect(decode(Array("メール café".utf8)) == .text("メール café")) + } + + @Test("Bytes that are not UTF-8 become replacement characters, never Latin 1 guesses") + func invalidUTF8IsNotFabricated() { + let eucJPMail: [UInt8] = [0xA5, 0xE1, 0xA1, 0xBC, 0xA5, 0xEB] + guard case .text(let text) = decode(eucJPMail) else { + Issue.record("expected text") + return + } + #expect(text.contains("\u{FFFD}")) + #expect(text != "¥á¡¼¥ë") + } + + @Test("A Latin 1 byte on its own is a replacement character, not é") + func latin1ByteIsReplaced() { + #expect(decode([0x63, 0x61, 0x66, 0xE9]) == .text("caf\u{FFFD}")) + } + + @Test("Empty text stays empty") + func emptyText() { + #expect(decode([]) == .text("")) + } + + @Test("Boolean t and f read as true and false") + func booleans() { + #expect(decode(Array("t".utf8), oid: Self.booleanOid) == .text("true")) + #expect(decode(Array("f".utf8), oid: Self.booleanOid) == .text("false")) + } + + @Test("bytea hex decodes to its bytes") + func byteaHex() { + #expect(decode(Array("\\xdeadbeef".utf8), oid: Self.byteaOid) == .bytes(Data([0xDE, 0xAD, 0xBE, 0xEF]))) + } + + @Test("A bytea value that is not in a bytea format stays text") + func byteaFallsBackToText() { + #expect(decode(Array("\\xzz".utf8), oid: Self.byteaOid) == .text("\\xzz")) + } +} diff --git a/TableProTests/Plugins/LibPQConnectionLossTests.swift b/TableProTests/Plugins/LibPQConnectionLossTests.swift new file mode 100644 index 0000000000..b7ab0cb124 --- /dev/null +++ b/TableProTests/Plugins/LibPQConnectionLossTests.swift @@ -0,0 +1,139 @@ +// +// LibPQConnectionLossTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("libpq connection loss") +struct LibPQConnectionLossTests { + private static let serverMessage = LibPQPluginError( + message: "FATAL: terminating connection due to idle-session timeout", + sqlState: "57P05", + detail: nil + ) + + private static func error(_ loss: LibPQConnectionLoss) -> LibPQConnectionLostError { + LibPQConnectionLostError(loss: loss, underlying: serverMessage) + } + + @Test("Only a known idle session counts as holding no transaction") + func unknownCountsAsOpen() { + #expect(!LibPQTransactionState.idle.mayHoldTransaction) + #expect(LibPQTransactionState.unknown.mayHoldTransaction) + #expect(LibPQTransactionState.active.mayHoldTransaction) + #expect(LibPQTransactionState.inTransaction.mayHoldTransaction) + #expect(LibPQTransactionState.inError.mayHoldTransaction) + } + + @Test("A statement never sent is reported as not run, behind the server's own message") + func notSentIsNotRun() { + let error = Self.error(.beforeSending(transactionMayBeOpen: false)) + #expect(error.pluginErrorMessage.hasPrefix(Self.serverMessage.message)) + #expect(error.pluginErrorMessage.contains("It was not run.")) + #expect(!error.pluginErrorMessage.contains("rolled back")) + } + + @Test("A statement never sent while a transaction may be open says the transaction was rolled back") + func notSentInsideTransaction() { + let error = Self.error(.beforeSending(transactionMayBeOpen: true)) + #expect(error.pluginErrorMessage.contains("It was not run, and any open transaction was rolled back.")) + } + + @Test("A statement lost after it was sent may or may not have run, whatever it was") + func sentHasUnknownOutcome() { + let error = Self.error(.afterSending) + #expect(error.pluginErrorMessage.hasPrefix(Self.serverMessage.message)) + #expect(error.pluginErrorMessage.contains("may or may not have completed")) + #expect(error.pluginErrorMessage.contains("rolled back unless this statement committed it")) + } + + @Test("A statement libpq never sent keeps the recorded transaction state; one it sent does not need it") + func sendStageDecidesThePhase() { + func loss(sent: Bool, _ state: LibPQTransactionState) -> LibPQConnectionLoss { + LibPQConnectionLoss(sent: sent, recordedState: state) + } + + #expect(loss(sent: false, .idle) == .beforeSending(transactionMayBeOpen: false)) + #expect(loss(sent: false, .inTransaction) == .beforeSending(transactionMayBeOpen: true)) + #expect(loss(sent: false, .unknown) == .beforeSending(transactionMayBeOpen: true)) + #expect(loss(sent: true, .idle) == .afterSending) + #expect(loss(sent: true, .inTransaction) == .afterSending) + } + + @Test("A server message ends the session on FATAL or PANIC severity") + func severityDecides() { + #expect(LibPQServerMessage.endsSession(severity: "FATAL", sqlState: "57P05")) + #expect(LibPQServerMessage.endsSession(severity: "PANIC", sqlState: "XX000")) + #expect(!LibPQServerMessage.endsSession(severity: "NOTICE", sqlState: "42P07")) + #expect(!LibPQServerMessage.endsSession(severity: "WARNING", sqlState: "25P01")) + #expect(!LibPQServerMessage.endsSession(severity: "ERROR", sqlState: "08003")) + } + + /// A server before 9.6 sends no non-localized severity, so the class is all there is to go on + /// when choosing which message to attach. It decides nothing on its own: the caller reads the + /// sink only once libpq reports `CONNECTION_BAD`, and clears it while the session is healthy. + @Test("Without a severity field, only connection and operator-intervention classes are kept") + func classFallbackForOldServers() { + #expect(LibPQServerMessage.endsSession(severity: nil, sqlState: "57P01")) + #expect(LibPQServerMessage.endsSession(severity: nil, sqlState: "08006")) + #expect(!LibPQServerMessage.endsSession(severity: nil, sqlState: "42P07")) + #expect(!LibPQServerMessage.endsSession(severity: nil, sqlState: "25P01")) + #expect(!LibPQServerMessage.endsSession(severity: nil, sqlState: nil)) + } + + @Test("The server's message and SQLSTATE stay on the error") + func keepsServerMessage() { + let error = Self.error(.beforeSending(transactionMayBeOpen: false)) + #expect(error.pluginSqlState == "57P05") + #expect(error.pluginErrorDetail == nil) + } + + @Test("The server's own detail stays the detail") + func keepsServerDetail() { + let underlying = LibPQPluginError( + message: "server closed the connection unexpectedly", sqlState: nil, detail: "hint" + ) + let error = LibPQConnectionLostError(loss: .afterSending, underlying: underlying) + #expect(error.pluginSqlState == nil) + #expect(error.pluginErrorDetail == "hint") + } +} + +/// The app reads a driver error's message to tell an authentication failure from a refusal, and +/// PostgreSQL sends those as a FATAL, which is exactly the shape that now carries an explanation +/// as well. Both classifiers have to keep working through it. +@Suite("libpq connection loss and the app's error classifiers") +@MainActor +struct LibPQConnectionLossClassifierTests { + @Test("an authentication FATAL lost with the connection is still an authentication failure") + func authenticationFailureStillFires() { + let underlying = LibPQPluginError( + message: "FATAL: password authentication failed for user \"app\"", + sqlState: "28P01", + detail: nil + ) + let error = LibPQConnectionLostError( + loss: .beforeSending(transactionMayBeOpen: false), underlying: underlying + ) + + #expect(DatabaseManager.shared.isAuthenticationFailure(error)) + } + + @Test("a read-only refusal lost with the connection is still classified, with the server's words") + func readOnlyRefusalStillFires() { + let underlying = LibPQPluginError( + message: "ERROR: cannot execute INSERT in a read-only transaction", + sqlState: "25006", + detail: nil + ) + let error = LibPQConnectionLostError(loss: .afterSending, underlying: underlying) + + let diagnosis = DatabaseWriteRejectionDiagnosis.classify(error) + #expect(diagnosis != nil) + #expect(diagnosis?.serverMessage.contains("read-only transaction") == true) + } +} diff --git a/TableProTests/Plugins/LibPQConnectionStringTests.swift b/TableProTests/Plugins/LibPQConnectionStringTests.swift new file mode 100644 index 0000000000..63b3f0f1b3 --- /dev/null +++ b/TableProTests/Plugins/LibPQConnectionStringTests.swift @@ -0,0 +1,111 @@ +// +// LibPQConnectionStringTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("LibPQConnectionString") +struct LibPQConnectionStringTests { + private func build( + user: String = "postgres", + password: String? = "hunter2", + sslConfig: SSLConfiguration = SSLConfiguration(), + options: String? = nil + ) -> String { + LibPQConnectionString.build( + host: "db.example.com", + port: 5_432, + user: user, + password: password, + database: "app", + sslConfig: sslConfig, + options: options + ) + } + + @Test("The base parameters are always present") + func baseParameters() { + let conninfo = build() + + #expect(conninfo.contains("host='db.example.com'")) + #expect(conninfo.contains("port='5432'")) + #expect(conninfo.contains("dbname='app'")) + #expect(conninfo.contains("user='postgres'")) + #expect(conninfo.contains("password='hunter2'")) + #expect(conninfo.contains("sslmode='disable'")) + } + + @Test("The session is pinned to UTF8 in the startup packet, so RESET ALL and DISCARD ALL keep it") + func pinsClientEncoding() { + #expect(build().contains("client_encoding='UTF8'")) + } + + @Test("Connection options still reach the server, after the pinned encoding") + func optionsFollowTheEncoding() { + let conninfo = build(options: "--cluster=my-cluster -c search_path=app") + + #expect(conninfo.hasSuffix("client_encoding='UTF8' options='--cluster=my-cluster -c search_path=app'")) + } + + @Test("A client_encoding in the connection options rides along in options, where the server applies it first") + func clientEncodingInOptionsStaysInOptions() { + let conninfo = build(options: "-c client_encoding=LATIN1") + + #expect(conninfo.contains("client_encoding='UTF8'")) + #expect(conninfo.contains("options='-c client_encoding=LATIN1'")) + } + + @Test("An empty user, password or options is left out") + func emptyValuesAreOmitted() { + let conninfo = build(user: "", password: "", options: "") + + #expect(!conninfo.contains("user=")) + #expect(!conninfo.contains("password=")) + #expect(!conninfo.contains("options=")) + #expect(conninfo.contains("client_encoding='UTF8'")) + } + + @Test("A CA certificate is sent only when the mode verifies it") + func caOnlyWhenVerifying() { + let requiring = build(sslConfig: SSLConfiguration(mode: .required, caCertificatePath: "/ca.pem")) + #expect(!requiring.contains("sslrootcert")) + + let verifying = build(sslConfig: SSLConfiguration(mode: .verifyCa, caCertificatePath: "/ca.pem")) + #expect(verifying.contains("sslmode='verify-ca'")) + #expect(verifying.contains("sslrootcert='/ca.pem'")) + } + + @Test("A client certificate and key reach libpq as sslcert and sslkey") + func clientCertificateIsSent() { + let conninfo = build(sslConfig: SSLConfiguration( + mode: .required, + clientCertificatePath: "/client.pem", + clientKeyPath: "/client.key" + )) + + #expect(conninfo.contains("sslcert='/client.pem'")) + #expect(conninfo.contains("sslkey='/client.key'")) + } + + @Test("Quotes and backslashes in values are escaped") + func escapesValues() { + let conninfo = build(user: "o'brien", password: "back\\slash", options: "-c application_name='x'") + + #expect(conninfo.contains("user='o\\'brien'")) + #expect(conninfo.contains("password='back\\\\slash'")) + #expect(conninfo.contains("options='-c application_name=\\'x\\''")) + } + + @Test("UTF8 and its PostgreSQL 8.0 name UNICODE both count as the pinned encoding") + func recognizesReportedEncoding() { + #expect(LibPQConnectionString.isClientEncoding(reportedByServer: "UTF8")) + #expect(LibPQConnectionString.isClientEncoding(reportedByServer: "UNICODE")) + #expect(LibPQConnectionString.isClientEncoding(reportedByServer: "utf8")) + #expect(!LibPQConnectionString.isClientEncoding(reportedByServer: "EUC_JP")) + #expect(!LibPQConnectionString.isClientEncoding(reportedByServer: "SQL_ASCII")) + #expect(!LibPQConnectionString.isClientEncoding(reportedByServer: nil)) + } +} diff --git a/TableProTests/Plugins/LibPQPendingResultDrainTests.swift b/TableProTests/Plugins/LibPQPendingResultDrainTests.swift new file mode 100644 index 0000000000..f65a1def90 --- /dev/null +++ b/TableProTests/Plugins/LibPQPendingResultDrainTests.swift @@ -0,0 +1,124 @@ +// +// LibPQPendingResultDrainTests.swift +// TableProTests +// + +import Foundation +import Testing + +private final class SimulatedLibPQConnection { + private var queue: [LibPQPendingResult] + private var activeCopy: LibPQCopy? + private let leavesCopyOnEnd: Bool + private(set) var endedCopies: [LibPQCopy] = [] + private(set) var reads = 0 + + init(results: [LibPQPendingResult], leavesCopyOnEnd: Bool = true) { + self.queue = results + self.leavesCopyOnEnd = leavesCopyOnEnd + } + + func nextResult() -> LibPQPendingResult? { + reads += 1 + if let activeCopy { return .copy(activeCopy) } + guard !queue.isEmpty else { return nil } + let next = queue.removeFirst() + if case .copy(let copy) = next { activeCopy = copy } + return next + } + + func endCopy(_ copy: LibPQCopy) { + endedCopies.append(copy) + guard leavesCopyOnEnd else { return } + activeCopy = nil + queue.insert(.completed, at: 0) + } + + func drain() -> LibPQDrainOutcome { + LibPQPendingResultDrain.drain(nextResult: nextResult, endCopy: endCopy) + } +} + +private func textual(_ direction: LibPQCopyDirection) -> LibPQCopy { + LibPQCopy(direction: direction, format: .textual) +} + +@Suite("LibPQPendingResultDrain") +struct LibPQPendingResultDrainTests { + @Test("An idle connection drains without ending anything") + func idleConnection() { + let connection = SimulatedLibPQConnection(results: []) + let outcome = connection.drain() + #expect(outcome == .idle) + #expect(outcome.abandonedCopy == nil) + #expect(!outcome.leavesConnectionUnusable) + #expect(connection.endedCopies.isEmpty) + } + + @Test("Ordinary results are read until libpq has none left") + func ordinaryResults() { + let connection = SimulatedLibPQConnection(results: [.completed, .completed, .completed]) + #expect(connection.drain() == .idle) + #expect(connection.endedCopies.isEmpty) + #expect(connection.reads == 4) + } + + @Test( + "A COPY that repeats its state until ended is ended once and reported", + arguments: [LibPQCopyDirection.copyIn, .copyOut, .copyBoth] + ) + func copyStateIsEndedAndReported(direction: LibPQCopyDirection) { + let connection = SimulatedLibPQConnection(results: [.copy(textual(direction))]) + let outcome = connection.drain() + #expect(connection.endedCopies == [textual(direction)]) + #expect(outcome.abandonedCopy == textual(direction)) + #expect(!outcome.leavesConnectionUnusable) + } + + @Test("A COPY behind an earlier statement is still reported, so nothing is discarded in silence") + func copyAfterAnotherStatementIsReported() { + let connection = SimulatedLibPQConnection(results: [.completed, .copy(textual(.copyIn))]) + let outcome = connection.drain() + #expect(outcome.abandonedCopy == textual(.copyIn)) + #expect(connection.endedCopies == [textual(.copyIn)]) + } + + @Test("Each COPY in a multi-statement string is ended in turn and the first is reported") + func consecutiveCopyStatements() { + let connection = SimulatedLibPQConnection( + results: [.completed, .copy(textual(.copyOut)), .copy(textual(.copyIn)), .completed] + ) + let outcome = connection.drain() + #expect(connection.endedCopies == [textual(.copyOut), textual(.copyIn)]) + #expect(outcome.abandonedCopy == textual(.copyOut)) + } + + @Test("The COPY format travels with the direction, because binary input cannot end with CopyDone") + func formatIsCarried() { + let binary = LibPQCopy(direction: .copyIn, format: .binary) + let connection = SimulatedLibPQConnection(results: [.copy(binary)]) + #expect(connection.drain().abandonedCopy == binary) + #expect(connection.endedCopies == [binary]) + } + + @Test("A COPY libpq refuses to leave stops the drain and marks the connection unusable") + func stuckCopyTerminates() { + let connection = SimulatedLibPQConnection(results: [.copy(textual(.copyIn))], leavesCopyOnEnd: false) + let outcome = connection.drain() + #expect(outcome.stuckInCopy == textual(.copyIn)) + #expect(outcome.leavesConnectionUnusable) + #expect(outcome.abandonedCopy == textual(.copyIn)) + #expect(connection.endedCopies == [textual(.copyIn)]) + #expect(connection.reads == 2) + } + + @Test("Every direction explains itself in query editor terms and names the COPY form it rejects") + func unsupportedMessages() { + #expect(LibPQCopyDirection.copyIn.unsupportedMessage.contains("COPY FROM STDIN")) + #expect(LibPQCopyDirection.copyIn.unsupportedMessage.contains("no rows were sent")) + #expect(LibPQCopyDirection.copyOut.unsupportedMessage.contains("COPY TO STDOUT")) + #expect(!LibPQCopyDirection.copyBoth.unsupportedMessage.isEmpty) + let messages = Set([LibPQCopyDirection.copyIn, .copyOut, .copyBoth].map(\.unsupportedMessage)) + #expect(messages.count == 3) + } +} diff --git a/TableProTests/Plugins/LibPQPluginErrorTests.swift b/TableProTests/Plugins/LibPQPluginErrorTests.swift new file mode 100644 index 0000000000..3bab0016e4 --- /dev/null +++ b/TableProTests/Plugins/LibPQPluginErrorTests.swift @@ -0,0 +1,65 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("LibPQPluginError result fields") +struct LibPQPluginErrorTests { + private static let undefinedFunctionFields: [Int32: String] = [ + Int32(UInt8(ascii: "C")): "42883", + Int32(UInt8(ascii: "P")): "134", + Int32(UInt8(ascii: "D")): "no function matches" + ] + + private static func decoded(_ fields: [Int32: String], message: String = "ERROR: failed") -> LibPQPluginError { + LibPQPluginError(message: message) { fields[$0] } + } + + @Test("SQLSTATE comes from the C field, never the statement position") + func sqlStateReadsTheSQLStateField() { + let error = Self.decoded(Self.undefinedFunctionFields) + #expect(error.sqlState == "42883") + #expect(error.pluginSqlState == "42883") + } + + @Test("Detail comes from the D field") + func detailReadsTheDetailField() { + let error = Self.decoded(Self.undefinedFunctionFields) + #expect(error.detail == "no function matches") + } + + @Test("Only the SQLSTATE and detail fields are read") + func readsNoOtherField() { + var requested: [Int32] = [] + _ = LibPQPluginError(message: "ERROR: failed") { field in + requested.append(field) + return nil + } + #expect(Set(requested) == [Int32(UInt8(ascii: "C")), Int32(UInt8(ascii: "D"))]) + } + + @Test("A result with no diagnostic fields carries no SQLSTATE") + func missingFieldsStayNil() { + let error = Self.decoded([:]) + #expect(error.sqlState == nil) + #expect(error.detail == nil) + } + + @Test("The error description names the SQLSTATE") + func descriptionNamesTheSQLState() { + let error = Self.decoded( + Self.undefinedFunctionFields, + message: "ERROR: function to_regclass(text) does not exist" + ) + #expect(error.errorDescription?.contains("(SQLSTATE: 42883)") == true) + } + + @Test("A decoded read-only transaction error is diagnosed as server-enforced read-only") + func readOnlyTransactionIsDiagnosed() { + let error = Self.decoded( + [Int32(UInt8(ascii: "C")): "25006"], + message: "ERROR: cannot execute INSERT in a read-only transaction" + ) + #expect(DatabaseWriteRejectionDiagnosis.classify(error) != nil) + } +} diff --git a/TableProTests/Plugins/LibPQStringConformanceTests.swift b/TableProTests/Plugins/LibPQStringConformanceTests.swift new file mode 100644 index 0000000000..f376342cb3 --- /dev/null +++ b/TableProTests/Plugins/LibPQStringConformanceTests.swift @@ -0,0 +1,87 @@ +// +// LibPQStringConformanceTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("LibPQStringConformance") +struct LibPQStringConformanceTests { + @Test("Every session turns standard_conforming_strings on") + func sessionSetupForcesStandardConformingStrings() { + #expect(LibPQStringConformance.enableStatement == "SET standard_conforming_strings TO on") + } + + @Test("The setup statement and the fallback read name the parameter the connection tracks") + func setupNamesTrackedParameter() { + #expect(LibPQStringConformance.enableStatement.lowercased().contains(LibPQStringConformance.parameterName)) + #expect(LibPQStringConformance.showQuery.lowercased().hasSuffix(LibPQStringConformance.parameterName)) + } + + @Test("Reported values parse the way the server spells them") + func parsesReportedValues() { + #expect(LibPQStringConformance.isOn("on") == true) + #expect(LibPQStringConformance.isOn("ON") == true) + #expect(LibPQStringConformance.isOn(" on ") == true) + #expect(LibPQStringConformance.isOn("off") == false) + #expect(LibPQStringConformance.isOn("Off") == false) + #expect(LibPQStringConformance.isOn(nil) == nil) + #expect(LibPQStringConformance.isOn("maybe") == nil) + } + + @Test("With the setting on, only quotes are doubled") + func escapeWhenConforming() { + #expect(LibPQStringConformance.escape("plain", standardConformingStrings: true) == "plain") + #expect(LibPQStringConformance.escape("it's", standardConformingStrings: true) == "it''s") + #expect(LibPQStringConformance.escape("back\\slash", standardConformingStrings: true) == "back\\slash") + } + + @Test("With the setting off, backslashes are doubled as well") + func escapeWhenNotConforming() { + #expect(LibPQStringConformance.escape("plain", standardConformingStrings: false) == "plain") + #expect(LibPQStringConformance.escape("it's", standardConformingStrings: false) == "it''s") + #expect(LibPQStringConformance.escape("back\\slash", standardConformingStrings: false) == "back\\\\slash") + } + + @Test("A backslash before a quote cannot close the literal when the setting is off") + func injectionPayloadStaysInsideLiteral() { + let payload = "\\'; CREATE TABLE injected (x int); --" + let escaped = LibPQStringConformance.escape(payload, standardConformingStrings: false) + #expect(escaped == "\\\\''; CREATE TABLE injected (x int); --") + #expect(Self.decodeLegacyLiteralBody(escaped) == payload) + } + + @Test("The escaped body decodes back to the original under either setting") + func roundTripsUnderBothSettings() { + let values = ["", "a", "it's", "back\\slash", "\\'", "''", "\\\\", "mixed \\' and ' and \\"] + for value in values { + let conforming = LibPQStringConformance.escape(value, standardConformingStrings: true) + #expect(conforming.replacingOccurrences(of: "''", with: "'") == value) + let legacy = LibPQStringConformance.escape(value, standardConformingStrings: false) + #expect(Self.decodeLegacyLiteralBody(legacy) == value) + } + } + + @Test("NUL characters are dropped under either setting") + func dropsNul() { + #expect(LibPQStringConformance.escape("a\0b", standardConformingStrings: true) == "ab") + #expect(LibPQStringConformance.escape("a\0b", standardConformingStrings: false) == "ab") + } + + private static func decodeLegacyLiteralBody(_ body: String) -> String { + var decoded = "" + var iterator = Array(body).makeIterator() + while let character = iterator.next() { + switch character { + case "\\": + if let next = iterator.next() { decoded.append(next) } + case "'": + if let next = iterator.next() { decoded.append(next) } + default: + decoded.append(character) + } + } + return decoded + } +} diff --git a/TableProTests/Plugins/MaintenanceOperationDescriptorTests.swift b/TableProTests/Plugins/MaintenanceOperationDescriptorTests.swift new file mode 100644 index 0000000000..671a1267b4 --- /dev/null +++ b/TableProTests/Plugins/MaintenanceOperationDescriptorTests.swift @@ -0,0 +1,316 @@ +// +// MaintenanceOperationDescriptorTests.swift +// TableProTests +// +// Maintenance used to be a list of bare operation names, so the app offered every one of them on +// every object: PostgreSQL skips a VACUUM on a view with a WARNING and still reports the success +// command tag VACUUM, and refuses a REINDEX on one outright. The kind sets below are what +// PostgreSQL 17.11 and SQLite 3.54.0 actually answered. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Maintenance operation descriptors") +struct MaintenanceOperationDescriptorTests { + private func postgres(_ name: String) throws -> PluginMaintenanceOperation { + try #require(PostgreSQLMaintenance.operations.first { $0.name == name }) + } + + private func sqlite(_ name: String) throws -> PluginMaintenanceOperation { + try #require(SQLiteMaintenance.operations.first { $0.name == name }) + } + + private func mysql(_ name: String) throws -> PluginMaintenanceOperation { + try #require(MySQLMaintenance.operations.first { $0.name == name }) + } + + private func postgresStatements( + _ operation: String, + table: String?, + schema: String?, + options: [String: String] = [:] + ) -> [String]? { + PostgreSQLMaintenance.statements( + operation: operation, + table: table, + schema: schema, + options: options, + connectedDatabase: "app", + capabilities: PostgreSQLCapabilities(serverVersion: 170_011) + ) + } + + // MARK: - PostgreSQL kinds + + @Test("CLUSTER is offered on a table and a materialized view and on nothing else") + func clusterKinds() throws { + let cluster = try postgres("CLUSTER") + + #expect(cluster.applies(to: .table)) + #expect(cluster.applies(to: .materializedView)) + #expect(!cluster.applies(to: .partitionedTable)) + #expect(!cluster.applies(to: .view)) + #expect(!cluster.applies(to: .foreignTable)) + } + + @Test("ANALYZE reaches a foreign table and VACUUM does not") + func foreignTableTakesAnalyzeOnly() throws { + #expect(try postgres("ANALYZE").applies(to: .foreignTable)) + #expect(try !postgres("VACUUM").applies(to: .foreignTable)) + } + + @Test("Nothing PostgreSQL offers applies to a view") + func noPostgresOperationAppliesToAView() { + #expect(PostgreSQLMaintenance.operations.allSatisfy { !$0.applies(to: .view) }) + } + + @Test("REINDEX covers a partitioned table, which CLUSTER cannot") + func partitionedTableKinds() throws { + #expect(try postgres("REINDEX").applies(to: .partitionedTable)) + #expect(try postgres("VACUUM").applies(to: .partitionedTable)) + #expect(try postgres("ANALYZE").applies(to: .partitionedTable)) + } + + // MARK: - PostgreSQL statements + + @Test("A PostgreSQL target is qualified with the schema it was handed") + func qualifiesTheSchema() { + #expect(postgresStatements("REINDEX", table: "orders", schema: "app") == ["REINDEX TABLE \"app\".\"orders\""]) + #expect(postgresStatements("ANALYZE", table: "orders", schema: "app") == ["ANALYZE \"app\".\"orders\""]) + #expect(postgresStatements("VACUUM", table: "orders", schema: "app") == ["VACUUM \"app\".\"orders\""]) + #expect(postgresStatements("CLUSTER", table: "orders", schema: "app") == ["CLUSTER \"app\".\"orders\""]) + } + + @Test("A schema that is nil or empty leaves the name unqualified") + func unqualifiedWithoutASchema() { + #expect(postgresStatements("REINDEX", table: "orders", schema: nil) == ["REINDEX TABLE \"orders\""]) + #expect(postgresStatements("REINDEX", table: "orders", schema: "") == ["REINDEX TABLE \"orders\""]) + } + + @Test("A quote in a schema or table name is doubled rather than closing the identifier") + func quotesAreEscaped() { + #expect( + postgresStatements("ANALYZE", table: "or\"ders", schema: "a\"pp") + == ["ANALYZE \"a\"\"pp\".\"or\"\"ders\""] + ) + } + + @Test("VACUUM renders the flags the descriptor declares") + func vacuumFlags() { + #expect( + postgresStatements( + "VACUUM", + table: "orders", + schema: "app", + options: ["full": "true", "analyze": "true", "verbose": "true"] + ) == ["VACUUM (FULL, ANALYZE, VERBOSE) \"app\".\"orders\""] + ) + #expect( + postgresStatements("VACUUM", table: "orders", schema: "app", options: ["analyze": "true"]) + == ["VACUUM (ANALYZE) \"app\".\"orders\""] + ) + #expect( + postgresStatements("VACUUM", table: "orders", schema: "app", options: ["full": "false"]) + == ["VACUUM \"app\".\"orders\""] + ) + } + + @Test("REINDEX renders its own VERBOSE flag before the TABLE keyword") + func reindexVerbose() { + #expect( + postgresStatements("REINDEX", table: "orders", schema: "app", options: ["verbose": "true"]) + == ["REINDEX (VERBOSE) TABLE \"app\".\"orders\""] + ) + } + + @Test("A PostgreSQL operation with no table falls back to the database-wide form") + func databaseWideForms() { + #expect(postgresStatements("VACUUM", table: nil, schema: "app") == ["VACUUM"]) + #expect( + postgresStatements("VACUUM", table: nil, schema: "app", options: ["analyze": "true"]) + == ["VACUUM (ANALYZE)"] + ) + #expect(postgresStatements("ANALYZE", table: nil, schema: "app") == ["ANALYZE"]) + /// From PostgreSQL 16 the database name is optional and the statement reindexes the one it is + /// connected to, which is what `PostgreSQLVersionedStatements.reindexDatabase` emits and what + /// 17.11 accepted when this was measured. The named form is the 12 to 15 arm, pinned by + /// `PostgreSQLVersionedStatementsTests`. + #expect(postgresStatements("REINDEX", table: nil, schema: "app") == ["REINDEX DATABASE CONCURRENTLY"]) + #expect(postgresStatements("CLUSTER", table: nil, schema: "app") == nil) + } + + @Test("An operation PostgreSQL does not have produces nothing") + func unknownPostgresOperation() { + #expect(postgresStatements("OPTIMIZE TABLE", table: "orders", schema: "app") == nil) + } + + // MARK: - SQLite + + @Test("SQLite VACUUM and Integrity Check name no object and ignore one") + func sqliteDatabaseScoped() throws { + let vacuum = try sqlite("VACUUM") + let integrity = try sqlite("Integrity Check") + + #expect(vacuum.scope == .database) + #expect(integrity.scope == .database) + #expect(vacuum.appliesTo.isEmpty) + #expect(integrity.appliesTo.isEmpty) + #expect(vacuum.target("orders") == nil) + #expect(integrity.target("orders") == nil) + #expect(SQLiteMaintenance.statements(operation: "VACUUM", table: "orders") == ["VACUUM"]) + #expect(SQLiteMaintenance.statements(operation: "Integrity Check", table: "orders") == ["PRAGMA integrity_check"]) + } + + @Test("SQLite ANALYZE and REINDEX are offered on a table and not on a view") + func sqliteObjectScoped() throws { + for name in ["ANALYZE", "REINDEX"] { + let operation = try sqlite(name) + #expect(operation.applies(to: .table)) + #expect(!operation.applies(to: .view)) + #expect(operation.scope == .objectOrDatabase) + } + #expect(SQLiteMaintenance.statements(operation: "ANALYZE", table: "orders") == ["ANALYZE `orders`"]) + #expect(SQLiteMaintenance.statements(operation: "REINDEX", table: nil) == ["REINDEX"]) + } + + @Test("A backtick in a SQLite object name is doubled") + func sqliteQuoting() { + #expect(SQLiteMaintenance.statements(operation: "ANALYZE", table: "or`ders") == ["ANALYZE `or``ders`"]) + } + + // MARK: - MySQL + + @Test("CHECK TABLE is the only MySQL operation offered on a view") + func mysqlViewKinds() throws { + #expect(try mysql("CHECK TABLE").applies(to: .view)) + #expect(try !mysql("OPTIMIZE TABLE").applies(to: .view)) + #expect(try !mysql("ANALYZE TABLE").applies(to: .view)) + #expect(try !mysql("REPAIR TABLE").applies(to: .view)) + } + + @Test("CHECK TABLE declares its mode, defaulting to MEDIUM") + func mysqlCheckMode() throws { + let check = try mysql("CHECK TABLE") + let mode = try #require(check.options.first) + + #expect(check.options.count == 1) + #expect(mode.key == "mode") + #expect(mode.defaultValue == "MEDIUM") + #expect(mode.choices == ["QUICK", "FAST", "MEDIUM", "EXTENDED", "CHANGED"]) + #expect(!mode.isToggle) + } + + @Test("A MySQL target is backtick-quoted and qualified only when a schema is handed over") + func mysqlStatements() { + #expect( + MySQLMaintenance.statements( + operation: "OPTIMIZE TABLE", table: "orders", schema: nil, options: [:], flavor: .mysql + ) == ["OPTIMIZE TABLE `orders`"] + ) + #expect( + MySQLMaintenance.statements( + operation: "OPTIMIZE TABLE", table: "orders", schema: "shop", options: [:], flavor: .mysql + ) == ["OPTIMIZE TABLE `shop`.`orders`"] + ) + #expect( + MySQLMaintenance.statements( + operation: "CHECK TABLE", table: "orders", schema: nil, options: [:], flavor: .mysql + ) == ["CHECK TABLE `orders` MEDIUM"] + ) + #expect( + MySQLMaintenance.statements( + operation: "CHECK TABLE", table: "orders", schema: nil, options: ["mode": "EXTENDED"], flavor: .mysql + ) == ["CHECK TABLE `orders` EXTENDED"] + ) + } + + /// The mode lands in the statement text unquoted and reaches here from an MCP client as well as + /// from the sheet's picker, so anything that is not a declared choice falls back to the default. + @Test("A check mode outside the declared choices falls back to the default") + func mysqlRejectsAnUndeclaredMode() { + #expect( + MySQLMaintenance.statements( + operation: "CHECK TABLE", + table: "orders", + schema: nil, + options: ["mode": "EXTENDED; DROP TABLE orders"], + flavor: .mysql + ) == ["CHECK TABLE `orders` MEDIUM"] + ) + } + + @Test("A MySQL operation with no table produces nothing, because the engine has no such form") + func mysqlNeedsATable() { + #expect(MySQLMaintenance.operations.allSatisfy { $0.scope == .object }) + #expect( + MySQLMaintenance.statements( + operation: "ANALYZE TABLE", table: nil, schema: "shop", options: [:], flavor: .mysql + ) == nil + ) + } + + @Test("TiDB and Databend refuse an operation their flavor does not offer") + func flavorGatesTheStatement() { + for flavor in [MySQLServerFlavor.tidb(version: nil), .databend] { + #expect( + MySQLMaintenance.statements( + operation: "REPAIR TABLE", table: "orders", schema: nil, options: [:], flavor: flavor + ) == nil + ) + #expect( + MySQLMaintenance.statements( + operation: "ANALYZE TABLE", table: "orders", schema: nil, options: [:], flavor: flavor + ) != nil + ) + } + } + + // MARK: - Every declared option is read + + /// An option the descriptor declares that the statement builder never reads is the hardcoding bug + /// in its other direction: the sheet would show a control that changes nothing. + @Test("Every option a driver declares changes the statement it is declared on") + func everyDeclaredOptionIsRead() throws { + for operation in PostgreSQLMaintenance.operations { + let base = try #require(postgresStatements( + operation.name, table: "orders", schema: "app", options: operation.defaultOptionValues + )) + for option in operation.options { + var changed = operation.defaultOptionValues + changed[option.key] = try alternative(to: option) + let result = try #require(postgresStatements( + operation.name, table: "orders", schema: "app", options: changed + )) + #expect(result != base, "\(operation.name) ignores its own '\(option.key)' option") + } + } + + for operation in MySQLMaintenance.operations { + let base = try #require(MySQLMaintenance.statements( + operation: operation.name, + table: "orders", + schema: nil, + options: operation.defaultOptionValues, + flavor: .mysql + )) + for option in operation.options { + var changed = operation.defaultOptionValues + changed[option.key] = try alternative(to: option) + let result = try #require(MySQLMaintenance.statements( + operation: operation.name, table: "orders", schema: nil, options: changed, flavor: .mysql + )) + #expect(result != base, "\(operation.name) ignores its own '\(option.key)' option") + } + } + } + + private func alternative(to option: PluginMaintenanceOption) throws -> String { + guard let choices = option.choices else { + return option.defaultValue == "true" ? "false" : "true" + } + return try #require(choices.first { $0 != option.defaultValue }) + } +} diff --git a/TableProTests/Plugins/MariaDBFieldClassifierTests.swift b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift index 883dbc64fd..f570a3fd43 100644 --- a/TableProTests/Plugins/MariaDBFieldClassifierTests.swift +++ b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift @@ -3,13 +3,10 @@ // TableProTests // -#if canImport(MySQLDriverPlugin) import Foundation import TableProPluginKit import Testing -@testable import MySQLDriverPlugin - @Suite("MariaDBFieldClassifier") struct MariaDBFieldClassifierTests { @Test("makeColumnMeta reads PRIMARY KEY, NOT NULL, and AUTO_INCREMENT flags") @@ -82,7 +79,6 @@ struct MariaDBFieldClassifierTests { bytes.withUnsafeBytes { MariaDBFieldClassifier.bitFieldToString($0) } } - @Test("BLOB family with binary charset routes to binary") func blobFamilyBinary() { for typeRaw: UInt32 in [249, 250, 251, 252] { @@ -147,4 +143,3 @@ struct MariaDBFieldClassifierTests { #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 255, charset: 63)) // GEOMETRY (handled upstream) } } -#endif diff --git a/TableProTests/Plugins/MariaDBTypeNameTests.swift b/TableProTests/Plugins/MariaDBTypeNameTests.swift index 2fb80741ae..af00cc4630 100644 --- a/TableProTests/Plugins/MariaDBTypeNameTests.swift +++ b/TableProTests/Plugins/MariaDBTypeNameTests.swift @@ -3,11 +3,8 @@ // TableProTests // -#if canImport(MySQLDriverPlugin) import Testing -@testable import MySQLDriverPlugin - @Suite("MariaDB type name resolution") struct MariaDBTypeNameTests { private func resolve(typeRaw: UInt32, charsetnr: UInt32 = 33, flags: UInt = 0, length: UInt = 0) -> String { @@ -117,4 +114,3 @@ struct MariaDBTypeNameTests { #expect(resolve(typeRaw: 252, charsetnr: 33, flags: 0, length: 100_000) == "LONGTEXT") } } -#endif diff --git a/TableProTests/Plugins/MySQLCharacterSetTests.swift b/TableProTests/Plugins/MySQLCharacterSetTests.swift new file mode 100644 index 0000000000..c6717f1f09 --- /dev/null +++ b/TableProTests/Plugins/MySQLCharacterSetTests.swift @@ -0,0 +1,73 @@ +// +// MySQLCharacterSetTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("MySQL character set decoding") +struct MySQLCharacterSetTests { + private func decode(_ bytes: [UInt8], as name: String) -> String { + bytes.withUnsafeBytes { MySQLCharacterSet(serverName: name).decode($0) } + } + + @Test("Server names are normalized, and utf8 means utf8mb3") + func namesAreNormalized() { + #expect(MySQLCharacterSet(serverName: "UTF8").name == "utf8mb3") + #expect(MySQLCharacterSet(serverName: " latin1 ") == .latin1) + #expect(MySQLCharacterSet(serverName: "utf8mb4") == .utf8mb4) + } + + @Test("UTF-8 text decodes as UTF-8") + func utf8Decodes() { + #expect(decode(Array("メール・記事紐付け".utf8), as: "utf8mb4") == "メール・記事紐付け") + #expect(decode(Array("😀".utf8), as: "utf8mb4") == "😀") + } + + @Test("Invalid UTF-8 is marked with a replacement character, not reinvented as Latin 1") + func invalidUTF8IsNotFabricated() { + #expect(decode([0x61, 0xFF, 0x62], as: "utf8mb4") == "a\u{FFFD}b") + #expect(decode([0x63, 0x61, 0x66, 0xE9], as: "utf8mb3") == "caf\u{FFFD}") + } + + @Test("A latin1 column holding UTF-8 bytes reads as that UTF-8") + func latin1HoldingUTF8() { + #expect(decode([0xE3, 0x83, 0xA1], as: "latin1") == "メ") + } + + @Test("A latin1 column holding Latin 1 text uses MySQL's own latin1, which is cp1252") + func latin1IsWindows1252() { + #expect(decode([0x69, 0x74, 0x92, 0x73, 0x20, 0x35, 0x80], as: "latin1") == "it’s 5€") + #expect(decode([0x63, 0x61, 0x66, 0xE9], as: "latin1") == "café") + } + + @Test("Charsets Foundation decodes byte-for-byte like the server use Foundation") + func foundationCharsets() { + #expect(decode([0xCF, 0xF0, 0xE8, 0xE2, 0xE5, 0xF2], as: "cp1251") == "Привет") + #expect(decode([0xB1, 0xE6], as: "latin2") == "ąć") + #expect(decode([0x83, 0x81, 0x81, 0x5B, 0x83, 0x8B, 0x5C], as: "cp932") == "メール\\") + #expect(decode([0xD6, 0xD0, 0xCE, 0xC4], as: "gbk") == "中文") + #expect(decode([0x30, 0xE1, 0x30, 0xFC], as: "utf16") == "メー") + #expect(decode([0xE1, 0x30, 0xFC, 0x30], as: "utf16le") == "メー") + } + + @Test("A byte a single-byte charset leaves undefined becomes one replacement character") + func undefinedSingleByteKeepsTheRest() { + #expect(decode([0x61, 0x81, 0x62], as: "cp1250") == "a\u{FFFD}b") + } + + @Test("Charsets whose Foundation mapping disagrees with the server are not in the table") + func mismatchedCharsetsAreExcluded() { + let decodable = Set(MySQLCharacterSet.singleByteDecodedNames + MySQLCharacterSet.multiByteDecodedNames) + for name in ["sjis", "ujis", "eucjpms", "big5", "euckr", "greek", "hebrew", "koi8r", "koi8u", "cp866", "latin7", "tis620"] { + #expect(!decodable.contains(name), "\(name)") + } + } + + @Test("A charset without a verified decoder reads valid UTF-8 as UTF-8") + func unknownCharsetFallsBackToUTF8() { + #expect(decode(Array("abc".utf8), as: "armscii8") == "abc") + #expect(decode([0x61, 0xFF], as: "armscii8") == "a\u{FFFD}") + } +} diff --git a/TableProTests/Plugins/MySQLColumnDecodingTests.swift b/TableProTests/Plugins/MySQLColumnDecodingTests.swift new file mode 100644 index 0000000000..37be254ad9 --- /dev/null +++ b/TableProTests/Plugins/MySQLColumnDecodingTests.swift @@ -0,0 +1,107 @@ +// +// MySQLColumnDecodingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("MySQL column decoding") +struct MySQLColumnDecodingTests { + private static let doubleEncodedMail = String(bytes: [0xC3, 0xA3, 0xC6, 0x92, 0xC2, 0xA1], encoding: .utf8) ?? "" + + private func decoding(type: UInt32, charset: UInt32, name: String? = "utf8mb4") -> MySQLColumnDecoding { + MySQLColumnDecoding(typeRaw: type, charsetnr: charset, characterSetName: name) + } + + private func decode( + _ bytes: [UInt8], + with decoding: MySQLColumnDecoding, + encoding: MySQLConnectionEncoding = .utf8 + ) -> PluginCellValue { + bytes.withUnsafeBytes { decoding.decode($0, encoding: encoding) } + } + + @Test("Each column kind gets its own decoding") + func columnKinds() { + #expect(decoding(type: 255, charset: 63) == .geometry) + #expect(decoding(type: 16, charset: 63) == .bit) + #expect(decoding(type: 252, charset: 63) == .bytes) + #expect(decoding(type: 253, charset: 63) == .bytes) + #expect(decoding(type: 253, charset: 8, name: "latin1") == .text(.latin1)) + #expect(decoding(type: 252, charset: 255, name: "utf8mb4") == .text(.utf8mb4)) + } + + @Test("Numbers, dates and JSON carry the binary charset and decode as text") + func binaryCharsetScalarsAreText() { + for type: UInt32 in [3, 8, 12, 245, 246] { + #expect(decoding(type: type, charset: 63, name: "binary") == .text(.utf8mb4)) + } + } + + @Test("Databend's booleans, hex-encoded binary and text geometry keep their own decoding") + func databendShapes() { + let boolean = MySQLColumnDecoding(typeRaw: 2, length: 1, charsetnr: 63, characterSetName: "binary", flavor: .databend) + #expect(boolean == .databendBoolean) + #expect(decode(Array("1".utf8), with: boolean) == .text("true")) + + let binary = MySQLColumnDecoding(typeRaw: 252, charsetnr: 63, characterSetName: "binary", flavor: .databend) + #expect(binary == .databendHexBytes) + #expect(decode(Array("CAFE".utf8), with: binary) == .bytes(Data([0xCA, 0xFE]))) + + let geometry = MySQLColumnDecoding(typeRaw: 255, charsetnr: 63, characterSetName: "binary", flavor: .databend) + #expect(geometry == .text(.utf8mb4)) + #expect(MySQLColumnDecoding(typeRaw: 2, length: 1, charsetnr: 63, characterSetName: "binary") == .text(.utf8mb4)) + } + + @Test("A collation id libmariadb does not know decodes as the session's UTF-8") + func unknownCollationIsUTF8() { + #expect(decoding(type: 253, charset: 309, name: nil) == .text(.utf8mb4)) + } + + @Test("The default encoding shows exactly what the server stored") + func defaultShowsStoredText() { + let stored = Array(Self.doubleEncodedMail.utf8) + #expect(decode(stored, with: .text(.utf8mb4)) == .text(Self.doubleEncodedMail)) + } + + @Test("UTF-8 via Latin 1 repairs text columns only") + func legacyModeRepairsText() { + let stored = Array(Self.doubleEncodedMail.utf8) + #expect(decode(stored, with: .text(.utf8mb4), encoding: .utf8ViaLatin1) == .text("メ")) + #expect(decode(stored, with: .bytes, encoding: .utf8ViaLatin1) == .bytes(Data(stored))) + } + + @Test("A result row keeps NULLs and decodes every other cell by its column") + func resultRow() { + var columns = MySQLResultColumns() + columns.append(name: "id", typeCode: 3, typeName: "INT", decoding: .text(.utf8mb4), flags: mysqlPriKeyFlag) + columns.append(name: "note", typeCode: 253, typeName: "VARCHAR", decoding: .text(.latin1), flags: 0) + columns.append(name: "missing", typeCode: 253, typeName: "VARCHAR", decoding: .text(.utf8mb4), flags: 0) + let cells: [[UInt8]?] = [Array("7".utf8), [0x63, 0x61, 0x66, 0xE9], nil] + let buffers = cells.map { cell in + cell.map { bytes in + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: bytes.count, alignment: 1) + buffer.copyBytes(from: bytes) + return buffer + } + } + defer { buffers.forEach { $0?.deallocate() } } + + let row = columns.row(encoding: .utf8) { index in + buffers[index].map { UnsafeRawBufferPointer($0) } + } + + #expect(row == [.text("7"), .text("café"), .null]) + #expect(columns.metadata.first?.isPrimaryKey == true) + } + + @Test("Names and messages read as UTF-8, or as MySQL latin1 when a latin1 session sent them") + func sessionText() { + let utf8 = Array("列名".utf8) + let latin1: [UInt8] = [0x63, 0x61, 0x66, 0xE9] + #expect(utf8.withUnsafeBytes { mysqlSessionText($0, encoding: .utf8) } == "列名") + #expect(latin1.withUnsafeBytes { mysqlSessionText($0, encoding: .utf8) } == "café") + } +} diff --git a/TableProTests/Plugins/MySQLConnectionEncodingTests.swift b/TableProTests/Plugins/MySQLConnectionEncodingTests.swift new file mode 100644 index 0000000000..c5080506c5 --- /dev/null +++ b/TableProTests/Plugins/MySQLConnectionEncodingTests.swift @@ -0,0 +1,89 @@ +// +// MySQLConnectionEncodingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MySQL connection encoding") +struct MySQLConnectionEncodingTests { + @Test("A missing, empty or unknown field value is plain UTF-8") + func fieldValueParsing() { + #expect(MySQLConnectionEncoding(fieldValue: nil) == .utf8) + #expect(MySQLConnectionEncoding(fieldValue: "") == .utf8) + #expect(MySQLConnectionEncoding(fieldValue: "sjis") == .utf8) + #expect(MySQLConnectionEncoding(fieldValue: "utf8ViaLatin1") == .utf8ViaLatin1) + } + + @Test("The connection's additional fields resolve to an encoding") + func additionalFieldsParsing() { + #expect(MySQLConnectionEncoding(additionalFields: [:]) == .utf8) + #expect(MySQLConnectionEncoding(additionalFields: ["mysqlConnectionEncoding": "utf8ViaLatin1"]) == .utf8ViaLatin1) + #expect(MySQLConnectionEncoding(additionalFields: ["other": "utf8ViaLatin1"]) == .utf8) + } + + @Test("Only UTF-8 via Latin 1 changes the client character set, and only the client's") + func sessionStatements() { + #expect(MySQLConnectionEncoding.utf8.sessionStatements.isEmpty) + #expect(MySQLConnectionEncoding.utf8ViaLatin1.sessionStatements == ["SET character_set_client = latin1"]) + #expect(MySQLConnectionEncoding.sessionCharacterSetName == "utf8mb4") + #expect(MySQLConnectionEncoding.sessionFallbackStatement == "SET NAMES utf8") + } + + @Test("The field is an advanced dropdown whose values the driver reads back") + func fieldShape() throws { + let field = MySQLConnectionEncoding.connectionField + #expect(field.id == MySQLConnectionEncoding.fieldId) + #expect(field.section == .advanced) + guard case .dropdown(let options) = field.fieldType else { + Issue.record("expected a dropdown") + return + } + #expect(options.map(\.value) == MySQLConnectionEncoding.allCases.map(\.rawValue)) + for option in options { + #expect(MySQLConnectionEncoding(fieldValue: option.value).rawValue == option.value) + } + } + + @Test("MySQL and MariaDB curate the same encoding field the plugin declares") + func curatedFieldsMatchThePlugin() throws { + let expected = try Self.encoded(MySQLConnectionEncoding.connectionField) + let curated = PluginMetadataRegistry.curatedDefaults() + for typeId in ["MySQL", "MariaDB"] { + let snapshot = try #require(curated.first { $0.typeId == typeId }?.snapshot) + let field = try #require( + snapshot.connection.additionalConnectionFields.first { $0.id == MySQLConnectionEncoding.fieldId }, + "\(typeId) curates no encoding field" + ) + #expect(try Self.encoded(field) == expected, "\(typeId)") + } + } + + @Test("The MySQL plugin declares the shared encoding field") + func pluginDeclaresTheField() throws { + let source = try String(contentsOf: Self.repositoryRoot().appendingPathComponent( + "Plugins/MySQLDriverPlugin/MySQLPlugin.swift" + ), encoding: .utf8) + #expect(source.contains("MySQLConnectionEncoding.connectionField")) + } + + private static func encoded(_ field: ConnectionField) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(field) + } + + private static func repositoryRoot(file: StaticString = #filePath) throws -> URL { + var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + while directory.path != "/" { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw CocoaError(.fileNoSuchFile) + } +} diff --git a/TableProTests/Plugins/MySQLLatin1Tests.swift b/TableProTests/Plugins/MySQLLatin1Tests.swift new file mode 100644 index 0000000000..e6e15332eb --- /dev/null +++ b/TableProTests/Plugins/MySQLLatin1Tests.swift @@ -0,0 +1,101 @@ +// +// MySQLLatin1Tests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("MySQL latin1") +struct MySQLLatin1Tests { + private static let serverUTF8ForUpperHalf: [String] = [ + "E282AC", "C281", "E2809A", "C692", "E2809E", "E280A6", "E280A0", "E280A1", + "CB86", "E280B0", "C5A0", "E280B9", "C592", "C28D", "C5BD", "C28F", + "C290", "E28098", "E28099", "E2809C", "E2809D", "E280A2", "E28093", "E28094", + "CB9C", "E284A2", "C5A1", "E280BA", "C593", "C29D", "C5BE", "C5B8", + "C2A0", "C2A1", "C2A2", "C2A3", "C2A4", "C2A5", "C2A6", "C2A7", + "C2A8", "C2A9", "C2AA", "C2AB", "C2AC", "C2AD", "C2AE", "C2AF", + "C2B0", "C2B1", "C2B2", "C2B3", "C2B4", "C2B5", "C2B6", "C2B7", + "C2B8", "C2B9", "C2BA", "C2BB", "C2BC", "C2BD", "C2BE", "C2BF", + "C380", "C381", "C382", "C383", "C384", "C385", "C386", "C387", + "C388", "C389", "C38A", "C38B", "C38C", "C38D", "C38E", "C38F", + "C390", "C391", "C392", "C393", "C394", "C395", "C396", "C397", + "C398", "C399", "C39A", "C39B", "C39C", "C39D", "C39E", "C39F", + "C3A0", "C3A1", "C3A2", "C3A3", "C3A4", "C3A5", "C3A6", "C3A7", + "C3A8", "C3A9", "C3AA", "C3AB", "C3AC", "C3AD", "C3AE", "C3AF", + "C3B0", "C3B1", "C3B2", "C3B3", "C3B4", "C3B5", "C3B6", "C3B7", + "C3B8", "C3B9", "C3BA", "C3BB", "C3BC", "C3BD", "C3BE", "C3BF" + ] + + private static let reporterMojibakeUTF8 = + "C3A3C692C2A1C3A3C692C2BCC3A3C692C2ABC3A3C692C2BBC3A8C2A8CB9CC3A4C2BAE280B9C3A7C2B4C290C3A4C2BBCB9CC3A3C281E28098" + + private func hex(_ text: String) -> String { + text.utf8.map { String(format: "%02X", $0) }.joined() + } + + private func text(fromHex hex: String) -> String { + var bytes: [UInt8] = [] + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + bytes.append(UInt8(hex[index.. MySQLSessionFootprint { + var footprint = MySQLSessionFootprint() + for statement in statements { + footprint.observe(statement) + } + return footprint + } + + /// The session that replaces a dropped one is a new one, and the statement is answered from + /// it. Measured on MySQL 8.4.11: `SELECT @probe` came back `NULL` where it had come back 42, + /// with nothing raised. A replay is therefore only for a session holding nothing. + @Test("A read is replayed only on a session that holds nothing") + func replayNeedsACleanSession() { + #expect(mysqlMayReplay("SELECT * FROM users", on: footprint(after: "SELECT 1"))) + + for statement in [ + "SET @total = 5", + "SET SESSION sql_mode = 'ANSI'", + "CREATE TEMPORARY TABLE staging (a INT)", + "PREPARE stmt FROM 'SELECT 1'", + "LOCK TABLES users WRITE", + "FLUSH TABLES WITH READ LOCK", + "HANDLER users OPEN", + "USE reporting", + "BEGIN", + "/*!40101 BEGIN */", + "CALL rebuild_report()", + "/*!40103 SET TIME_ZONE='+00:00' */", + ] { + #expect(!mysqlMayReplay("SELECT * FROM users", on: footprint(after: statement)), "\(statement)") + } + + var serverSideTransaction = MySQLSessionFootprint() + serverSideTransaction.observeServerTransaction(isOpen: true) + #expect(!mysqlMayReplay("SELECT * FROM users", on: serverSideTransaction)) + } + + /// These change nothing, so the footprint stays clean, and their answer still belongs to the + /// session that ran the statement before them. Measured on MySQL 8.4.11: a fresh connection + /// answers `SELECT LAST_INSERT_ID()` with `0`, so an `INSERT`, a dropped connection and a + /// replayed read showed `0` for a row that had an id. + @Test("A read of a session-scoped value is never replayed") + func sessionScopedReadsAreNotReplayed() { + for query in [ + "SELECT LAST_INSERT_ID()", + "SELECT ROW_COUNT()", + "SELECT FOUND_ROWS()", + "SELECT CONNECTION_ID()", + "select last_insert_id()", + ] { + #expect(!mysqlMayReplay(query, on: MySQLSessionFootprint()), "\(query)") + } + } + + @Test("A statement that is unsafe on its own is not replayed on a clean session either") + func replayStillNeedsASafeStatement() { + #expect(!mysqlMayReplay("UPDATE users SET name = 'a'", on: MySQLSessionFootprint())) + #expect(!mysqlMayReplay("SELECT GET_LOCK('job', 10)", on: MySQLSessionFootprint())) + } } diff --git a/TableProTests/Plugins/ObjectCatalogQueryTests.swift b/TableProTests/Plugins/ObjectCatalogQueryTests.swift index f6ca78bddc..d19786e212 100644 --- a/TableProTests/Plugins/ObjectCatalogQueryTests.swift +++ b/TableProTests/Plugins/ObjectCatalogQueryTests.swift @@ -16,7 +16,7 @@ struct PostgreSQLObjectQueryTests { /// name once per overload, which is what produced duplicate rows and an arbitrary definition. @Test("Routine listing reads pg_proc, never information_schema") func routineListReadsPgProc() { - let sql = PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 160_000) + let sql = PostgreSQLObjectQueries.routineList(schema: "public", capabilities: .assumingModernWhenUnknown(160_000)) #expect(sql.contains("pg_catalog.pg_proc")) #expect(!sql.contains("information_schema")) #expect(sql.contains("p.oid::text")) @@ -27,19 +27,19 @@ struct PostgreSQLObjectQueryTests { /// `proisagg`, which PostgreSQL 11 dropped, and failed the listing on every current server. @Test("An unknown server version reads as modern, not ancient") func unknownVersionIsModern() { - #expect(PostgreSQLObjectQueries.usesProkind(serverVersionNumber: 0)) - #expect(PostgreSQLObjectQueries.usesProkind(serverVersionNumber: 170_000)) - #expect(!PostgreSQLObjectQueries.usesProkind(serverVersionNumber: 100_000)) - #expect(!PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 0) + #expect(PostgreSQLCapabilities.assumingModernWhenUnknown(0).hasProcedureKind) + #expect(PostgreSQLCapabilities(serverVersion: 170_000).hasProcedureKind) + #expect(!PostgreSQLCapabilities(serverVersion: 100_000).hasProcedureKind) + #expect(!PostgreSQLObjectQueries.routineList(schema: "public", capabilities: .assumingModernWhenUnknown(0)) .contains("proisagg")) } @Test("Aggregates and window functions are excluded because pg_get_functiondef raises on them") func aggregatesExcluded() { - let modern = PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 160_000) + let modern = PostgreSQLObjectQueries.routineList(schema: "public", capabilities: .assumingModernWhenUnknown(160_000)) #expect(modern.contains("p.prokind IN ('f', 'p')")) - let legacy = PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 100_000) + let legacy = PostgreSQLObjectQueries.routineList(schema: "public", capabilities: .assumingModernWhenUnknown(100_000)) #expect(legacy.contains("NOT p.proisagg AND NOT p.proiswindow")) #expect(!legacy.contains("prokind IN")) } @@ -70,9 +70,17 @@ struct PostgreSQLObjectQueryTests { #expect(one.contains("pg_catalog.pg_get_triggerdef")) } + @Test("Trigger events are joined with concat_ws, which 9.1 has and array_remove does not") + func triggerEventsUseConcatWs() { + let sql = PostgreSQLObjectQueries.triggerList(schema: "public", table: nil) + #expect(sql.contains("concat_ws(' OR ',")) + #expect(!sql.contains("array_remove")) + #expect(!sql.contains("array_to_string")) + } + @Test("A quote in a name or schema is escaped in every query") func literalsAreEscaped() { - let list = PostgreSQLObjectQueries.routineList(schema: "it's", serverVersionNumber: 160_000) + let list = PostgreSQLObjectQueries.routineList(schema: "it's", capabilities: .assumingModernWhenUnknown(160_000)) #expect(list.contains("'it''s'")) let byName = PostgreSQLObjectQueries.routineDefinitionByName( @@ -83,6 +91,28 @@ struct PostgreSQLObjectQueryTests { let triggers = PostgreSQLObjectQueries.triggerList(schema: "public", table: "o'brien") #expect(triggers.contains("'o''brien'")) } + + @Test("A backslash before a quote in a name becomes an E'' literal in every query") + func backslashNamesUseEscapeStringLiterals() { + let hostile = "a\\'; DROP TABLE victim; --" + let expected = "E'a\\\\''; DROP TABLE victim; --'" + + let list = PostgreSQLObjectQueries.routineList( + schema: hostile, capabilities: .assumingModernWhenUnknown(160_000) + ) + #expect(list.contains("n.nspname = \(expected)")) + + let byName = PostgreSQLObjectQueries.routineDefinitionByName( + name: hostile, schema: hostile, arguments: hostile + ) + #expect(byName.contains("p.proname = \(expected)")) + #expect(byName.contains("n.nspname = \(expected)")) + #expect(byName.contains("')' = \(expected)")) + + let triggers = PostgreSQLObjectQueries.triggerList(schema: hostile, table: hostile) + #expect(triggers.contains("c.relname = \(expected)")) + #expect(triggers.contains("n.nspname = \(expected)")) + } } @Suite("MySQL Object Catalog Queries") @@ -172,7 +202,7 @@ struct MSSQLObjectQueryTests { let all = MSSQLObjectQueries.triggerList(schema: "dbo", table: nil) let one = MSSQLObjectQueries.triggerList(schema: "dbo", table: "Orders") #expect(!all.contains("parent.name =")) - #expect(one.contains("parent.name = 'Orders'")) + #expect(one.contains("parent.name = N'Orders'")) #expect(all.contains("sys.trigger_events")) } @@ -186,8 +216,23 @@ struct MSSQLObjectQueryTests { @Test("A quote in a schema or table is escaped") func literalsAreEscaped() { - #expect(MSSQLObjectQueries.routineList(schema: "it's").contains("'it''s'")) - #expect(MSSQLObjectQueries.triggerList(schema: "dbo", table: "o'brien").contains("'o''brien'")) + #expect(MSSQLObjectQueries.routineList(schema: "it's").contains("N'it''s'")) + #expect(MSSQLObjectQueries.triggerList(schema: "dbo", table: "o'brien").contains("N'o''brien'")) + } + + @Test("A non-ASCII schema, routine or table name is an nvarchar literal") + func catalogNamesAreNationalLiterals() { + #expect(MSSQLObjectQueries.routineList(schema: "販売").contains("s.name = N'販売'")) + let definition = MSSQLObjectQueries.routineDefinition(schema: "販売", name: "集計") + #expect(definition.contains("s.name = N'販売' AND o.name = N'集計'")) + let triggers = MSSQLObjectQueries.triggerList(schema: "販売", table: "注文") + #expect(triggers.contains("s.name = N'販売'")) + #expect(triggers.contains("parent.name = N'注文'")) + } + + @Test("Fixed catalog type codes stay plain literals") + func catalogTypeCodesStayPlain() { + #expect(MSSQLObjectQueries.routineList(schema: "dbo").contains("o.type IN ('P', 'FN', 'IF', 'TF')")) } } diff --git a/TableProTests/Plugins/PostGISSpatialRewriteTests.swift b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift index c636b9a1ae..9ff84c21e0 100644 --- a/TableProTests/Plugins/PostGISSpatialRewriteTests.swift +++ b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift @@ -8,44 +8,62 @@ import Testing @Suite("PostGISSpatialRewrite.conversionQuery") struct PostGISConversionQueryTests { - @Test("geometry maps to the geometry conversion query") - func geometry() { - #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "geometry") - == PostGISSpatialRewrite.geometryConversionQuery) + private let geometry = PostGISType(name: "geometry", schema: "public") + private let geography = PostGISType(name: "geography", schema: "gis") + + @Test("geometry casts each element to the probed geometry type") + func geometryQuery() throws { + let query = try #require(PostGISSpatialRewrite.conversionQuery(for: geometry)) + #expect(query.contains("\"public\".ST_AsEWKT(($1::text[])[i]::\"public\".\"geometry\")")) } - @Test("geography maps to the geography conversion query") - func geography() { - #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "geography") - == PostGISSpatialRewrite.geographyConversionQuery) + @Test("geography casts each element to the probed geography type") + func geographyQuery() throws { + let query = try #require(PostGISSpatialRewrite.conversionQuery(for: geography)) + #expect(query.contains("\"gis\".ST_AsEWKT(($1::text[])[i]::\"gis\".\"geography\")")) } @Test("Unknown type name returns nil") func unknown() { - #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "text") == nil) - #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "raster") == nil) - #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "") == nil) + #expect(PostGISSpatialRewrite.conversionQuery(for: PostGISType(name: "text", schema: "public")) == nil) + #expect(PostGISSpatialRewrite.conversionQuery(for: PostGISType(name: "raster", schema: "public")) == nil) + #expect(PostGISSpatialRewrite.conversionQuery(for: PostGISType(name: "", schema: "public")) == nil) } - @Test("geometry query applies ST_AsEWKT over a text array parameter cast per element") - func geometryQueryShape() { - let query = PostGISSpatialRewrite.geometryConversionQuery - #expect(query.contains("ST_AsEWKT(t::geometry)")) - #expect(query.contains("unnest($1::text[])")) - #expect(query.contains("ORDER BY ord")) + @Test("Elements are walked by generate_subscripts, which 9.1 has, in array order") + func portableOrdering() throws { + let query = try #require(PostGISSpatialRewrite.conversionQuery(for: geometry)) + #expect(query.contains("pg_catalog.generate_subscripts($1::text[], 1) AS i ORDER BY i")) + #expect(!query.contains("WITH ORDINALITY")) + #expect(!query.contains("unnest")) } - @Test("geography query casts each element to geography") - func geographyQueryShape() { - let query = PostGISSpatialRewrite.geographyConversionQuery - #expect(query.contains("ST_AsEWKT(t::geography)")) - #expect(query.contains("unnest($1::text[])")) + @Test("A schema name is quoted as an identifier, not interpolated") + func schemaIsQuoted() throws { + let query = try #require( + PostGISSpatialRewrite.conversionQuery(for: PostGISType(name: "geometry", schema: "we\"ird")) + ) + #expect(query.contains("\"we\"\"ird\".\"geometry\"")) } @Test("Conversion query reads a single bound parameter, never the user statement") - func singleParameter() { - #expect(PostGISSpatialRewrite.geometryConversionQuery.contains("$1")) - #expect(!PostGISSpatialRewrite.geometryConversionQuery.contains("$2")) + func singleParameter() throws { + let query = try #require(PostGISSpatialRewrite.conversionQuery(for: geometry)) + #expect(query.contains("$1")) + #expect(!query.contains("$2")) + } + + @Test("The savepoint that isolates the conversion inside a transaction is released on both paths") + func savepointStatements() { + #expect(PostGISSpatialRewrite.savepoint == "SAVEPOINT tablepro_spatial_render") + #expect(PostGISSpatialRewrite.rollbackToSavepoint == "ROLLBACK TO SAVEPOINT tablepro_spatial_render") + #expect(PostGISSpatialRewrite.releaseSavepoint == "RELEASE SAVEPOINT tablepro_spatial_render") + } + + @Test("The probe reads each spatial type's namespace") + func probeReadsNamespace() { + #expect(PostGISSpatialRewrite.probeQuery.contains("n.nspname")) + #expect(PostGISSpatialRewrite.probeQuery.contains("JOIN pg_catalog.pg_namespace n")) } } diff --git a/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift b/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift index cdb0db3e8d..db8b1fbf10 100644 --- a/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift +++ b/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift @@ -17,7 +17,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("Always selects base tables and views from information_schema") func alwaysIncludesBaseTables() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: true, includeForeignTables: true ) @@ -27,7 +27,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("Omits the pg_matviews union when materialized views are unavailable") func omitsMatviewsWhenAbsent() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: false, includeForeignTables: true ) @@ -37,7 +37,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("Includes the pg_matviews union when materialized views are available") func includesMatviewsWhenPresent() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: true, includeForeignTables: false ) @@ -47,7 +47,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("Omits the pg_foreign_table union when foreign tables are unavailable") func omitsForeignTablesWhenAbsent() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: true, includeForeignTables: false ) @@ -57,7 +57,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("With no optional catalogs, only the base query remains") func baseOnlyWhenNoOptionalCatalogs() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: false, includeForeignTables: false ) diff --git a/TableProTests/Plugins/PostgreSQLCatalogForeignKeysTests.swift b/TableProTests/Plugins/PostgreSQLCatalogForeignKeysTests.swift new file mode 100644 index 0000000000..dcff6d5ea9 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLCatalogForeignKeysTests.swift @@ -0,0 +1,186 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQLCatalogForeignKeys") +struct PostgreSQLCatalogForeignKeysTests { + private func row( + identity: String = "100", + name: String, + referencedSchema: String? = "app", + referencedTable: String = "parent", + deleteAction: String = "a", + updateAction: String = "a", + sourceKeys: String, + referencedKeys: String, + side: String, + attributeNumber: Int, + attributeName: String + ) -> [String?] { + var cells: [String?] = Array(repeating: nil, count: PostgreSQLCatalogForeignKeys.Column.allCases.count) + cells[PostgreSQLCatalogForeignKeys.Column.constraintIdentity.rawValue] = identity + cells[PostgreSQLCatalogForeignKeys.Column.constraintName.rawValue] = name + cells[PostgreSQLCatalogForeignKeys.Column.referencedSchema.rawValue] = referencedSchema + cells[PostgreSQLCatalogForeignKeys.Column.referencedTable.rawValue] = referencedTable + cells[PostgreSQLCatalogForeignKeys.Column.deleteAction.rawValue] = deleteAction + cells[PostgreSQLCatalogForeignKeys.Column.updateAction.rawValue] = updateAction + cells[PostgreSQLCatalogForeignKeys.Column.sourceKeys.rawValue] = sourceKeys + cells[PostgreSQLCatalogForeignKeys.Column.referencedKeys.rawValue] = referencedKeys + cells[PostgreSQLCatalogForeignKeys.Column.side.rawValue] = side + cells[PostgreSQLCatalogForeignKeys.Column.attributeNumber.rawValue] = String(attributeNumber) + cells[PostgreSQLCatalogForeignKeys.Column.attributeName.rawValue] = attributeName + return cells + } + + private func pairs(_ keys: [PluginForeignKeyInfo]) -> [String] { + keys.map { "\($0.name):\($0.column)->\($0.referencedTable).\($0.referencedColumn)" } + } + + @Test("A composite key yields one pair per column, in key order") + func compositeKeyPairsByPosition() { + let rows = [ + row(name: "fk_xy", sourceKeys: "{2,3}", referencedKeys: "{1,2}", side: "r", attributeNumber: 1, attributeName: "x"), + row(name: "fk_xy", sourceKeys: "{2,3}", referencedKeys: "{1,2}", side: "r", attributeNumber: 2, attributeName: "y"), + row(name: "fk_xy", sourceKeys: "{2,3}", referencedKeys: "{1,2}", side: "s", attributeNumber: 2, attributeName: "a"), + row(name: "fk_xy", sourceKeys: "{2,3}", referencedKeys: "{1,2}", side: "s", attributeNumber: 3, attributeName: "b") + ] + let keys = PostgreSQLCatalogForeignKeys.foreignKeys(from: rows) + #expect(pairs(keys) == ["fk_xy:a->parent.x", "fk_xy:b->parent.y"]) + } + + @Test("Columns listed in a different order than the referenced key pair by the key arrays, not by attribute order") + func reorderedReferencePairsByKeyArrays() { + let rows = [ + row(name: "fk_ba", sourceKeys: "{3,2}", referencedKeys: "{2,1}", side: "s", attributeNumber: 2, attributeName: "a"), + row(name: "fk_ba", sourceKeys: "{3,2}", referencedKeys: "{2,1}", side: "s", attributeNumber: 3, attributeName: "b"), + row(name: "fk_ba", sourceKeys: "{3,2}", referencedKeys: "{2,1}", side: "r", attributeNumber: 1, attributeName: "x"), + row(name: "fk_ba", sourceKeys: "{3,2}", referencedKeys: "{2,1}", side: "r", attributeNumber: 2, attributeName: "y") + ] + let keys = PostgreSQLCatalogForeignKeys.foreignKeys(from: rows) + #expect(pairs(keys) == ["fk_ba:b->parent.y", "fk_ba:a->parent.x"]) + } + + @Test("Two constraints with the same name stay separate when their identities differ") + func sameNameDifferentConstraintsStaySeparate() { + let rows = [ + row(identity: "1", name: "fk", referencedTable: "one", sourceKeys: "{1}", referencedKeys: "{1}", side: "s", attributeNumber: 1, attributeName: "a"), + row(identity: "1", name: "fk", referencedTable: "one", sourceKeys: "{1}", referencedKeys: "{1}", side: "r", attributeNumber: 1, attributeName: "id"), + row(identity: "2", name: "fk", referencedTable: "two", sourceKeys: "{2}", referencedKeys: "{1}", side: "s", attributeNumber: 2, attributeName: "b"), + row(identity: "2", name: "fk", referencedTable: "two", sourceKeys: "{2}", referencedKeys: "{1}", side: "r", attributeNumber: 1, attributeName: "key") + ] + let keys = PostgreSQLCatalogForeignKeys.foreignKeys(from: rows) + #expect(pairs(keys) == ["fk:a->one.id", "fk:b->two.key"]) + } + + @Test("A self-referencing composite key pairs source and referenced attributes of the same table") + func selfReferenceUsesBothSides() { + let rows = [ + row(name: "tree_self", referencedTable: "tree", sourceKeys: "{3,4}", referencedKeys: "{1,2}", side: "s", attributeNumber: 3, attributeName: "pp"), + row(name: "tree_self", referencedTable: "tree", sourceKeys: "{3,4}", referencedKeys: "{1,2}", side: "s", attributeNumber: 4, attributeName: "pq"), + row(name: "tree_self", referencedTable: "tree", sourceKeys: "{3,4}", referencedKeys: "{1,2}", side: "r", attributeNumber: 1, attributeName: "p"), + row(name: "tree_self", referencedTable: "tree", sourceKeys: "{3,4}", referencedKeys: "{1,2}", side: "r", attributeNumber: 2, attributeName: "q") + ] + let keys = PostgreSQLCatalogForeignKeys.foreignKeys(from: rows) + #expect(pairs(keys) == ["tree_self:pp->tree.p", "tree_self:pq->tree.q"]) + } + + @Test("Referenced schema and referential actions come from the catalog row") + func carriesSchemaAndActions() throws { + let rows = [("s", "Weird, Col"), ("r", "id")].map { side, attributeName in + row( + name: "fk", + referencedSchema: "billing", + deleteAction: "c", + updateAction: "n", + sourceKeys: "{1}", + referencedKeys: "{1}", + side: side, + attributeNumber: 1, + attributeName: attributeName + ) + } + let key = try #require(PostgreSQLCatalogForeignKeys.foreignKeys(from: rows).first) + #expect(key.column == "Weird, Col") + #expect(key.referencedSchema == "billing") + #expect(key.onDelete == "CASCADE") + #expect(key.onUpdate == "SET NULL") + } + + @Test("Every referential action code maps to its SQL keyword") + func referentialActionCodes() { + #expect(PostgreSQLCatalogForeignKeys.referentialAction("a") == "NO ACTION") + #expect(PostgreSQLCatalogForeignKeys.referentialAction("r") == "RESTRICT") + #expect(PostgreSQLCatalogForeignKeys.referentialAction("c") == "CASCADE") + #expect(PostgreSQLCatalogForeignKeys.referentialAction("n") == "SET NULL") + #expect(PostgreSQLCatalogForeignKeys.referentialAction("d") == "SET DEFAULT") + #expect(PostgreSQLCatalogForeignKeys.referentialAction(nil) == "NO ACTION") + } + + @Test("Key arrays of different lengths produce no pairs rather than a guessed pairing") + func mismatchedKeyArraysProduceNothing() { + let rows = [ + row(name: "fk", sourceKeys: "{1,2}", referencedKeys: "{1}", side: "s", attributeNumber: 1, attributeName: "a"), + row(name: "fk", sourceKeys: "{1,2}", referencedKeys: "{1}", side: "r", attributeNumber: 1, attributeName: "x") + ] + #expect(PostgreSQLCatalogForeignKeys.foreignKeys(from: rows).isEmpty) + } + + @Test("A row whose key array cannot be read is skipped") + func unreadableKeyArrayIsSkipped() { + let rows = [ + row(name: "fk", sourceKeys: "1,2", referencedKeys: "{1,2}", side: "s", attributeNumber: 1, attributeName: "a") + ] + #expect(PostgreSQLCatalogForeignKeys.foreignKeys(from: rows).isEmpty) + } + + @Test("The query filters on the table's own schema and name and reads both key arrays") + func queryFiltersOnSchemaAndTable() { + let query = PostgreSQLCatalogForeignKeys.query( + schema: "sales", + table: "orders", + excludesPartitionClones: true + ) + #expect(query.contains("ns.nspname = 'sales'")) + #expect(query.contains("cl.relname = 'orders'")) + #expect(query.contains("a.attnum = ANY (c.conkey)")) + #expect(query.contains("a.attnum = ANY (c.confkey)")) + #expect(!query.contains("information_schema")) + } + + @Test("Both branches project every decoded column") + func branchesProjectEveryColumn() { + let query = PostgreSQLCatalogForeignKeys.query( + schema: "public", + table: "t", + excludesPartitionClones: false + ) + let branches = query.components(separatedBy: "UNION ALL") + #expect(branches.count == PostgreSQLCatalogForeignKeys.Side.allCases.count) + } + + @Test("Partition clones are excluded from PostgreSQL 11 on, where conparentid exists") + func partitionCloneGateFollowsServerVersion() { + #expect(!PostgreSQLCatalogForeignKeys.excludesPartitionClones(serverVersionNumber: 0)) + #expect(!PostgreSQLCatalogForeignKeys.excludesPartitionClones(serverVersionNumber: 80_002)) + #expect(!PostgreSQLCatalogForeignKeys.excludesPartitionClones(serverVersionNumber: 100_021)) + #expect(PostgreSQLCatalogForeignKeys.excludesPartitionClones(serverVersionNumber: 110_016)) + #expect(PostgreSQLCatalogForeignKeys.excludesPartitionClones(serverVersionNumber: 170_011)) + } + + @Test("A server without conparentid is never sent the clone filter") + func cloneFilterOmittedWhenUnsupported() { + let filtered = PostgreSQLCatalogForeignKeys.query( + schema: "public", + table: "t", + excludesPartitionClones: true + ) + let plain = PostgreSQLCatalogForeignKeys.query( + schema: "public", + table: "t", + excludesPartitionClones: false + ) + #expect(filtered.contains("parent.oid = c.conparentid")) + #expect(filtered.contains("parent.conrelid = c.conrelid")) + #expect(!plain.contains("conparentid")) + } +} diff --git a/TableProTests/Plugins/PostgreSQLColumnQueryTests.swift b/TableProTests/Plugins/PostgreSQLColumnQueryTests.swift index f489466645..e22521a508 100644 --- a/TableProTests/Plugins/PostgreSQLColumnQueryTests.swift +++ b/TableProTests/Plugins/PostgreSQLColumnQueryTests.swift @@ -15,25 +15,24 @@ import Testing @Suite("PostgreSQLSchemaQueries.columnsQuery") struct PostgreSQLColumnsQueryTests { + private let modern = PostgreSQLCapabilities(serverVersion: 170_000) + private let legacy = PostgreSQLCapabilities(serverVersion: 90_100) + private func singleTable(schema: String, table: String) -> String { PostgreSQLSchemaQueries.columnsQuery( - schemaLiteral: schema, - tableLiteral: table, - identityProjection: "a.attidentity", - generatedProjection: "a.attgenerated", - generationExpressionProjection: "c.generation_expression", - attributeJoin: "LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = st.relid" + schema: schema, + table: table, + capabilities: modern, + includeMaterializedViews: true ) } private func allTables(schema: String) -> String { PostgreSQLSchemaQueries.columnsQuery( - schemaLiteral: schema, - tableLiteral: nil, - identityProjection: "NULL::text", - generatedProjection: "NULL::text", - generationExpressionProjection: "NULL::text", - attributeJoin: "" + schema: schema, + table: nil, + capabilities: legacy, + includeMaterializedViews: false ) } @@ -60,26 +59,191 @@ struct PostgreSQLColumnsQueryTests { func singleTableOmitsTableNameColumn() { let query = singleTable(schema: "s2", table: "orders") #expect(!query.contains("c.table_name,")) - #expect(query.contains("ORDER BY c.ordinal_position")) + #expect(query.contains("ORDER BY cols.ordinal_position")) + #expect(!query.contains("ORDER BY c.ordinal_position")) #expect(query.contains("pk ON c.column_name = pk.column_name")) } @Test("all-tables query selects table_name, drops the table filter, and orders by table") func allTablesProjectsTableName() { let query = allTables(schema: "s2") - #expect(query.contains("c.table_name,")) + #expect(query.contains("c.table_name AS table_name,")) #expect(query.contains("WHERE c.table_schema = 's2'")) #expect(!query.contains("c.table_name = '")) - #expect(query.contains("ORDER BY c.table_name, c.ordinal_position")) + #expect(query.contains("ORDER BY cols.table_name, cols.ordinal_position")) + #expect(!query.contains("ORDER BY c.table_name, c.ordinal_position")) #expect(query.contains("pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name")) } - @Test("version-dependent projections are interpolated verbatim") - func projectionsInterpolated() { + @Test("identity and generated flags are read from pg_attribute by attribute number on 10 and later") + func modernServerReadsAttributes() { let query = singleTable(schema: "s2", table: "orders") #expect(query.contains("a.attidentity")) #expect(query.contains("a.attgenerated")) - #expect(query.contains("LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = st.relid")) + #expect(query.contains("c.generation_expression")) + #expect(query.contains("ON a.attrelid = rel.oid")) + #expect(query.contains("AND a.attnum = c.ordinal_position")) + } + + @Test("a server without identity or generated columns never names pg_attribute") + func legacyServerSkipsAttributes() { + let query = allTables(schema: "s2") + #expect(!query.contains("pg_attribute")) + #expect(!query.contains("a.attidentity")) + #expect(!query.contains("a.attgenerated")) + #expect(!query.contains("c.generation_expression")) + } + + @Test("column comments are read through the relation's pg_class oid, not a statistics view") + func commentsKeyOnRelationOid() { + for query in [singleTable(schema: "s2", table: "orders"), allTables(schema: "s2")] { + #expect(!query.contains("pg_statio_all_tables")) + #expect(query.contains("ON rel.relnamespace = relns.oid")) + #expect(query.contains("pg_catalog.col_description(rel.oid, c.ordinal_position)")) + } + } + + @Test("primary key columns are matched to the constraint's own table") + func primaryKeyJoinIsTableScoped() { + for query in [singleTable(schema: "s2", table: "orders"), allTables(schema: "s2")] { + #expect(query.contains("AND tc.table_name = kcu.table_name")) + } + } +} + +@Suite("PostgreSQLSchemaQueries.columnsQuery materialized views") +struct PostgreSQLMaterializedViewColumnsQueryTests { + private let modern = PostgreSQLCapabilities(serverVersion: 170_000) + private let legacy = PostgreSQLCapabilities(serverVersion: 90_100) + + private static let armMarkers = [ + "UNION ALL", + "mvc.relkind = 'm'", + "pg_catalog.pg_attribute mva", + "pg_catalog.format_type" + ] + + private static let outerColumns = [ + "cols.column_name", + "cols.data_type", + "cols.is_nullable", + "cols.column_default", + "cols.collation_name", + "cols.column_comment", + "cols.udt_name", + "cols.is_pk", + "cols.identity_kind", + "cols.generated_kind", + "cols.udt_schema", + "cols.generation_expression" + ] + + private func query( + schema: String = "s2", + table: String? = "orders", + capabilities: PostgreSQLCapabilities? = nil, + includeMaterializedViews: Bool = true + ) -> String { + PostgreSQLSchemaQueries.columnsQuery( + schema: schema, + table: table, + capabilities: capabilities ?? modern, + includeMaterializedViews: includeMaterializedViews + ) + } + + @Test("the materialized view arm appears only when the catalog has materialized views") + func armFollowsTheFlag() { + let included = query() + let excluded = query(includeMaterializedViews: false) + for marker in Self.armMarkers { + #expect(included.contains(marker)) + #expect(!excluded.contains(marker)) + } + } + + @Test("the outer projection is exactly the order the row mapper reads") + func outerProjectionMatchesMapperOrder() { + let singleTable = query() + #expect(singleTable.contains("SELECT\n \(Self.outerColumns.joined(separator: ",\n "))\nFROM (")) + let allTables = query(table: nil) + let withTableName = ["cols.table_name"] + Self.outerColumns + #expect(allTables.contains("SELECT\n \(withTableName.joined(separator: ",\n "))\nFROM (")) + for rendered in [singleTable, allTables] { + #expect(!rendered.contains("cols.ordinal_position,")) + } + } + + @Test("ordering carries through the union on the derived table's ordinal") + func orderingCarriesThroughTheUnion() { + #expect(query().contains("ORDER BY cols.ordinal_position")) + #expect(query(table: nil).contains("ORDER BY cols.table_name, cols.ordinal_position")) + } + + @Test("the arm is scoped to the same schema and table as the information_schema arm", + arguments: ["s2", "analytics", "public"]) + func armIsScopedLikeTheBase(schema: String) { + let singleTable = query(schema: schema) + #expect(singleTable.contains("mvn.nspname = '\(schema)'")) + #expect(singleTable.contains("mvc.relname = 'orders'")) + let allTables = query(schema: schema, table: nil) + #expect(allTables.contains("mvc.relname AS table_name")) + #expect(!allTables.contains("mvc.relname = '")) + } + + @Test("a materialized view column has no primary key and no default") + func armSkipsConstraintsAndDefaults() { + let rendered = query() + #expect(rendered.contains("'NO' AS is_pk")) + #expect(rendered.contains("NULL::text AS column_default")) + #expect(!rendered.contains("pg_attrdef")) + #expect(rendered.components(separatedBy: "information_schema.table_constraints").count - 1 == 1) + } + + @Test("identity and generated mirror the capability switches the base arm uses") + func armMirrorsCapabilitySwitches() { + let modernQuery = query() + #expect(modernQuery.contains("mva.attidentity AS identity_kind")) + #expect(modernQuery.contains("mva.attgenerated AS generated_kind")) + let legacyQuery = query(capabilities: legacy) + #expect(legacyQuery.contains("mvc.relkind = 'm'")) + #expect(!legacyQuery.contains("mva.attidentity")) + #expect(!legacyQuery.contains("mva.attgenerated")) + } + + @Test("type names are spelled the way information_schema spells them, without a typmod") + func armSpellsTypesLikeInformationSchema() { + let rendered = query() + #expect(rendered.contains("pg_catalog.format_type(mva.atttypid, NULL)")) + #expect(rendered.contains("pg_catalog.format_type(mvt.typbasetype, NULL)")) + #expect(!rendered.contains("atttypmod")) + } + + @Test("a domain resolves to its base type, as information_schema does") + func armResolvesDomainsToBaseTypes() { + let rendered = query() + #expect(rendered.contains("mvt.typtype = 'd'")) + #expect(rendered.contains("mvbt.oid = mvt.typbasetype")) + #expect(rendered.contains("COALESCE(mvbt.typname, mvt.typname) AS udt_name")) + #expect(rendered.contains("COALESCE(mvbtn.nspname, mvtn.nspname) AS udt_schema")) + } + + @Test("the default collation is reported as absent rather than as a collation named default") + func armSuppressesTheDefaultCollation() { + let rendered = query() + #expect(rendered.contains( + "CASE WHEN mvcon.nspname <> 'pg_catalog' OR mvco.collname <> 'default' " + + "THEN mvco.collname END AS collation_name" + )) + } + + @Test("the arm carries the comment source and the visibility filters information_schema applies") + func armKeepsCommentsAndPrivileges() { + let rendered = query() + #expect(rendered.contains("pg_catalog.col_description(mvc.oid, mva.attnum) AS column_comment")) + #expect(rendered.contains("pg_catalog.pg_has_role(mvc.relowner")) + #expect(rendered.contains("pg_catalog.has_column_privilege(mvc.oid")) + #expect(rendered.contains("NOT pg_catalog.pg_is_other_temp_schema(mvn.oid)")) } } @@ -87,7 +251,7 @@ struct PostgreSQLColumnsQueryTests { struct RedshiftColumnsQueryTests { @Test("single-table query filters on the requested schema and table") func singleTableFiltersOnRequestedSchema() { - let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: "s2", tableLiteral: "orders") + let query = RedshiftSchemaQueries.columnsQuery(schema: "s2", table: "orders") #expect(query.contains("WHERE c.table_schema = 's2' AND c.table_name = 'orders'")) #expect(query.contains("AND tc.table_schema = 's2'")) #expect(query.contains("AND tc.table_name = 'orders'")) @@ -97,17 +261,34 @@ struct RedshiftColumnsQueryTests { @Test("a non-active schema is not ignored", arguments: ["s2", "analytics", "public"]) func nonActiveSchemaThreadsThrough(schema: String) { - let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: schema, tableLiteral: "orders") + let query = RedshiftSchemaQueries.columnsQuery(schema: schema, table: "orders") #expect(query.contains("c.table_schema = '\(schema)'")) } @Test("all-tables query selects table_name, drops the table filter, and orders by table") func allTablesProjectsTableName() { - let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: "s2", tableLiteral: nil) + let query = RedshiftSchemaQueries.columnsQuery(schema: "s2", table: nil) #expect(query.contains("c.table_name,")) #expect(query.contains("WHERE c.table_schema = 's2'")) #expect(!query.contains("c.table_name = '")) #expect(query.contains("ORDER BY c.table_name, c.ordinal_position")) #expect(query.contains("pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name")) } + + @Test("primary key columns are matched to the constraint's own table") + func primaryKeyJoinIsTableScoped() { + for table in ["orders", nil] { + let query = RedshiftSchemaQueries.columnsQuery(schema: "s2", table: table) + #expect(query.contains("AND tc.table_name = kcu.table_name")) + } + } + + @Test("Redshift keeps a single-arm read and never names relkind") + func redshiftKeepsASingleArmRead() { + for table in ["orders", nil] { + let query = RedshiftSchemaQueries.columnsQuery(schema: "s2", table: table) + #expect(!query.contains("UNION ALL")) + #expect(!query.contains("relkind")) + } + } } diff --git a/TableProTests/Plugins/PostgreSQLCommentStatementsTests.swift b/TableProTests/Plugins/PostgreSQLCommentStatementsTests.swift new file mode 100644 index 0000000000..f53ae8bf15 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLCommentStatementsTests.swift @@ -0,0 +1,164 @@ +// +// PostgreSQLCommentStatementsTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("PostgreSQL comment statements") +struct PostgreSQLCommentStatementsTests { + private func row(relkind: String, column: String?, description: String?) -> [String?] { + [relkind, column, description] + } + + @Test("The relation's own comment is rendered first, then its columns in row order") + func relationCommentPrecedesColumns() { + let statements = PostgreSQLCommentStatements.statements( + name: "orders", + schema: "app", + rows: [ + row(relkind: "r", column: nil, description: "Orders table"), + row(relkind: "r", column: "id", description: "Primary key"), + row(relkind: "r", column: "label", description: "Label") + ]) + + #expect(statements == [ + "COMMENT ON TABLE \"app\".\"orders\" IS 'Orders table'", + "COMMENT ON COLUMN \"app\".\"orders\".\"id\" IS 'Primary key'", + "COMMENT ON COLUMN \"app\".\"orders\".\"label\" IS 'Label'" + ]) + } + + @Test("relkind picks the keyword, because the server refuses a mismatched one") + func relkindPicksTheKeyword() { + #expect(PostgreSQLRelationSQL.commentKeyword(forRelkind: "r") == "TABLE") + #expect(PostgreSQLRelationSQL.commentKeyword(forRelkind: "p") == "TABLE") + #expect(PostgreSQLRelationSQL.commentKeyword(forRelkind: "f") == "FOREIGN TABLE") + #expect(PostgreSQLRelationSQL.commentKeyword(forRelkind: "v") == "VIEW") + #expect(PostgreSQLRelationSQL.commentKeyword(forRelkind: "m") == "MATERIALIZED VIEW") + #expect(PostgreSQLRelationSQL.commentKeyword(forRelkind: "i") == nil) + } + + @Test("A view, a materialized view and a foreign table each get their own keyword") + func eachRelationKindRendersItsKeyword() { + let view = PostgreSQLCommentStatements.statements( + name: "v_orders", schema: "app", + rows: [row(relkind: "v", column: nil, description: "View comment")]) + let matview = PostgreSQLCommentStatements.statements( + name: "m_orders", schema: "app", + rows: [row(relkind: "m", column: nil, description: "Matview comment")]) + let foreign = PostgreSQLCommentStatements.statements( + name: "f_orders", schema: "app", + rows: [row(relkind: "f", column: nil, description: "Foreign comment")]) + + #expect(view == ["COMMENT ON VIEW \"app\".\"v_orders\" IS 'View comment'"]) + #expect(matview == ["COMMENT ON MATERIALIZED VIEW \"app\".\"m_orders\" IS 'Matview comment'"]) + #expect(foreign == ["COMMENT ON FOREIGN TABLE \"app\".\"f_orders\" IS 'Foreign comment'"]) + } + + @Test("A relkind with no COMMENT keyword yields no statement") + func unknownRelkindYieldsNothing() { + let statements = PostgreSQLCommentStatements.statements( + name: "orders_pkey", schema: "app", + rows: [row(relkind: "i", column: nil, description: "Index comment")]) + + #expect(statements.isEmpty) + } + + @Test("A value holding a backslash and a quote renders the server's own quote_literal output") + func backslashAndQuoteRenderAsEscapeString() { + let statements = PostgreSQLCommentStatements.statements( + name: "orders", schema: "app", + rows: [row( + relkind: "r", + column: "path", + description: #"Windows path C:\temp and a quote ' here"#)]) + + #expect(statements == [ + #"COMMENT ON COLUMN "app"."orders"."path" IS E'Windows path C:\\temp and a quote '' here'"# + ]) + } + + @Test("A comment with a single quote and no backslash stays a plain literal") + func singleQuoteDoublesInPlainLiteral() { + let statements = PostgreSQLCommentStatements.statements( + name: "orders", schema: "app", + rows: [row(relkind: "r", column: "label", description: "It's a label")]) + + #expect(statements == ["COMMENT ON COLUMN \"app\".\"orders\".\"label\" IS 'It''s a label'"]) + } + + @Test("A multi-line comment stays inside one literal") + func multiLineCommentStaysOneLiteral() { + let statements = PostgreSQLCommentStatements.statements( + name: "orders", schema: "app", + rows: [row(relkind: "r", column: "notes", description: "First line\nSecond line")]) + + #expect(statements == [ + "COMMENT ON COLUMN \"app\".\"orders\".\"notes\" IS 'First line\nSecond line'" + ]) + } + + @Test("A double quote in an identifier is doubled") + func doubleQuoteInIdentifierIsDoubled() { + let statements = PostgreSQLCommentStatements.statements( + name: "od\"d", schema: "sc\"h", + rows: [row(relkind: "r", column: "co\"l", description: "Comment")]) + + #expect(statements == ["COMMENT ON COLUMN \"sc\"\"h\".\"od\"\"d\".\"co\"\"l\" IS 'Comment'"]) + } + + @Test("A nil or empty description yields no statement, never IS NULL") + func emptyDescriptionYieldsNothing() { + let statements = PostgreSQLCommentStatements.statements( + name: "orders", schema: "app", + rows: [ + row(relkind: "r", column: nil, description: nil), + row(relkind: "r", column: "id", description: "") + ]) + + #expect(statements.isEmpty) + } + + @Test("A short row is skipped rather than read past its end") + func shortRowIsSkipped() { + let statements = PostgreSQLCommentStatements.statements( + name: "orders", schema: "app", rows: [["r", nil]]) + + #expect(statements.isEmpty) + } + + @Test("catalogQuery quotes both literals so a name holding a quote cannot end them") + func catalogQueryQuotesItsLiterals() { + let query = PostgreSQLCommentStatements.catalogQuery(name: "O'Brien", schema: "sa'les") + + #expect(query.contains("c.relname = 'O''Brien'")) + #expect(query.contains("n.nspname = 'sa''les'")) + #expect(!query.contains("'O'Brien'")) + } + + @Test("catalogQuery reads only the relation kinds a COMMENT statement can name") + func catalogQueryFiltersRelationKinds() { + let query = PostgreSQLCommentStatements.catalogQuery(name: "orders", schema: "app") + + #expect(query.contains("c.relkind IN ('r', 'p', 'f', 'v', 'm')")) + } + + @Test("catalogQuery excludes dropped and system columns and orders the relation before them") + func catalogQueryExcludesDroppedColumns() { + let query = PostgreSQLCommentStatements.catalogQuery(name: "orders", schema: "app") + + #expect(query.contains("NOT a.attisdropped")) + #expect(query.contains("a.attnum > 0")) + #expect(query.contains("ORDER BY ordinal, attnum")) + } + + @Test("catalogQuery reads both description functions and drops a relation with neither") + func catalogQueryReadsBothDescriptionFunctions() { + let query = PostgreSQLCommentStatements.catalogQuery(name: "orders", schema: "app") + + #expect(query.contains("pg_catalog.obj_description(c.oid, 'pg_class') IS NOT NULL")) + #expect(query.contains("pg_catalog.col_description(c.oid, a.attnum) IS NOT NULL")) + } +} diff --git a/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift b/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift index a45d5a547e..6c3b3e126c 100644 --- a/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift +++ b/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift @@ -16,7 +16,7 @@ struct PostgreSQLDefaultSchemaFallbackTests { @Test("resolves the first existing search path entry, omitting missing schemas") func firstSearchPathSchemaQuery() { - #expect(PostgreSQLSchemaQueries.firstSearchPathSchema == "SELECT current_schemas(false)[1]") + #expect(PostgreSQLSchemaQueries.firstSearchPathSchema == "SELECT (current_schemas(false))[1]") } @Test("falls back to the effective search path before the alphabetical schema list") diff --git a/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift index a8e84e30b9..2c19101357 100644 --- a/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift +++ b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift @@ -7,18 +7,57 @@ struct PostgreSQLFetchTablesCommentTests { @Test("Base query selects the table comment via obj_description") func baseQuerySelectsComment() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: false, includeForeignTables: false ) #expect(query.contains("table_comment")) - #expect(query.contains("obj_description")) + #expect(query.contains("obj_description(pc.oid, 'pg_class')")) + } + + @Test("No rung reads to_regclass, whose text form exists only from PostgreSQL 9.6") + func noRungUsesToRegclass() { + let attempts = PostgreSQLTableListingLadder.degradableAttempts + [PostgreSQLTableListingLadder.leastCapableAttempt] + for attempt in attempts { + let query = PostgreSQLSchemaQueries.fetchTables( + schema: "public", + includeMaterializedViews: attempt.includeOptionalCatalogs, + includeForeignTables: attempt.includeOptionalCatalogs, + includeComments: attempt.includeComments, + includePartitionAwareness: attempt.includePartitionAwareness + ) + #expect(!query.contains("to_regclass"), "\(attempt.label)") + } + } + + @Test("Comments without partition awareness still join pg_class for the relation oid") + func commentsWithoutPartitionsKeepTheClassJoin() { + let query = PostgreSQLSchemaQueries.fetchTables( + schema: "public", + includeMaterializedViews: false, + includeForeignTables: false, + includeComments: true, + includePartitionAwareness: false + ) + #expect(query.contains("LEFT JOIN pg_catalog.pg_class pc")) + #expect(!query.contains("pg_catalog.pg_inherits")) + } + + @Test("A materialized view's comment comes from its own relation oid") + func matviewCommentUsesItsOid() { + let query = PostgreSQLSchemaQueries.fetchTables( + schema: "public", + includeMaterializedViews: true, + includeForeignTables: false + ) + #expect(query.contains("obj_description(mc.oid, 'pg_class')")) + #expect(query.contains("JOIN pg_catalog.pg_class mc ON mc.relnamespace = mn.oid AND mc.relname = m.matviewname")) } @Test("Fully degraded query does not reference pg_class/pg_namespace so the portability fallback stays minimal") func fallbackQueryStaysPortable() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: false, includeForeignTables: false, includeComments: false, @@ -32,7 +71,7 @@ struct PostgreSQLFetchTablesCommentTests { @Test("Every union branch projects a comment column so columns stay aligned") func allBranchesProjectComment() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: true, includeForeignTables: true ) @@ -44,7 +83,7 @@ struct PostgreSQLFetchTablesCommentTests { @Test("Comment-free fallback omits obj_description but keeps the aligned comment column") func commentFreeFallbackOmitsObjDescription() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: true, includeForeignTables: true, includeComments: false diff --git a/TableProTests/Plugins/PostgreSQLLegacyCatalogQueryTests.swift b/TableProTests/Plugins/PostgreSQLLegacyCatalogQueryTests.swift new file mode 100644 index 0000000000..a363995fe7 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLLegacyCatalogQueryTests.swift @@ -0,0 +1,427 @@ +// +// PostgreSQLLegacyCatalogQueryTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQL catalog reads that PostgreSQL 9.1 accepts") +struct PostgreSQLLegacyCatalogQueryTests { + private static let constructsMissingBefore96 = [ + "to_regclass", "LATERAL", "WITH ORDINALITY", "json", "array_remove", "array_position", "FILTER (", + "unnest(" + ] + + static let legacy = PostgreSQLCapabilities(serverVersion: 90_124) + + private static func unavailableConstructs(in sql: String) -> [String] { + constructsMissingBefore96.filter { sql.contains($0) } + } + + @Test("Every rewritten read stays inside what PostgreSQL 9.1 parses") + func rewrittenReadsArePortable() { + let legacy = Self.legacy + let queries = [ + PostgreSQLForeignKeyQueries.foreignKeyList(schema: "public", table: "orders", capabilities: legacy), + PostgreSQLForeignKeyQueries.foreignKeyList(schema: "public", table: nil, capabilities: legacy), + PostgreSQLIndexQueries.indexList(schema: "public", table: "orders"), + PostgreSQLIndexQueries.indexList(schema: "public", table: nil), + PostgreSQLObjectQueries.triggerList(schema: "public", table: nil), + PostgreSQLObjectQueries.userDefinedTypeList(schema: "public", identity: nil, capabilities: legacy), + PostgreSQLSchemaQueries.checkConstraintsQuery(schema: "public", table: "t"), + PostgreSQLSchemaQueries.collationList(capabilities: legacy), + PostgreSQLSchemaQueries.allTablesMetadata(schema: "public"), + PostgreSQLPrincipalQueries.databaseGrants(role: "r"), + PostgreSQLPrincipalQueries.schemaGrants(role: "r"), + PostgreSQLPrincipalQueries.tableGrants(role: "r"), + PostgreSQLPrincipalQueries.columnGrants(role: "r"), + PostgreSQLSequenceQueries.sequenceList(schema: "public", dependentOnTable: "orders", source: .sequenceParameters), + PostgreSQLSchemaQueries.fetchTables(schema: "public", includeMaterializedViews: true, includeForeignTables: true), + PostgreSQLViewDefinition.catalogQuery(name: "v", schema: "public") + ] + for sql in queries { + #expect(Self.unavailableConstructs(in: sql).isEmpty, "\(sql)") + } + } + + @Test("Every name reaching these reads is quoted so standard_conforming_strings cannot change it") + func namesAreQuotedInEveryRead() { + let hostile = "a\\b'c" + let queries = [ + PostgreSQLObjectQueries.triggerList(schema: hostile, table: hostile), + PostgreSQLObjectQueries.routineList(schema: hostile, capabilities: Self.legacy), + PostgreSQLSchemaQueries.checkConstraintsQuery(schema: hostile, table: hostile), + PostgreSQLSchemaQueries.fetchTables( + schema: hostile, includeMaterializedViews: true, includeForeignTables: true + ) + ] + for sql in queries { + #expect(sql.contains("E'a\\\\b''c'"), "\(sql)") + #expect(!sql.contains("'a\\b'c'")) + } + } + + @Test("The search-path fallback parenthesises the function before subscripting it") + func searchPathFallbackParses() { + #expect(PostgreSQLSchemaQueries.firstSearchPathSchema == "SELECT (current_schemas(false))[1]") + } +} + +@Suite("PostgreSQL foreign key catalog read") +struct PostgreSQLForeignKeyQueryTests { + private static let modern = PostgreSQLCapabilities(serverVersion: 170_011) + private static let beforeConstraintParent = PostgreSQLCapabilities(serverVersion: 100_021) + + @Test("Schema and table predicates sit inside the derived table, before the key positions expand") + func predicatesAreInsideTheDerivedTable() throws { + let sql = PostgreSQLForeignKeyQueries.foreignKeyList(schema: "public", table: "orders", capabilities: Self.modern) + let derivedEnd = try #require(sql.range(of: ") con")) + let inside = sql[.. (100)::numeric)") + ] + let decoded = try #require(PostgreSQLIndexRow.index(from: row)) + #expect(decoded.index.columns == ["amount"]) + #expect(decoded.index.isUnique) + #expect(!decoded.index.isPrimary) + #expect(decoded.index.whereClause == "(amount > (100)::numeric)") + } +} + +@Suite("PostgreSQL check constraint columns") +struct PostgreSQLCheckConstraintColumnTests { + @Test("Column names come back whole from the array literal the server prints") + func hostileNames() { + let columns = PostgreSQLTextArray.values( #"{"a b","c,d","q\"t","x{y}"}"#) + #expect(columns == ["a b", "c,d", "q\"t", "x{y}"]) + } + + @Test("A constraint that names no column reads as an empty list") + func noColumns() { + #expect(PostgreSQLTextArray.values( "{}").isEmpty) + #expect(PostgreSQLTextArray.values( nil).isEmpty) + } + + @Test("The query aggregates names with array_agg and defaults to an empty array") + func queryShape() { + let sql = PostgreSQLSchemaQueries.checkConstraintsQuery(schema: "public", table: "t") + #expect(sql.contains("array_agg(att.attname ORDER BY att.attnum)::text")) + #expect(sql.contains("att.attnum = ANY (con.conkey)")) + #expect(sql.contains("'{}'")) + } +} + +@Suite("PostgreSQL sequence reads") +struct PostgreSQLSequenceQueryTests { + @Test("pg_sequences is read wherever it exists, and every other server reads the sequences one by one") + func sourceSelection() { + #expect(PostgreSQLSequenceQueries.source(hasSequencesCatalog: true) == .sequencesView) + #expect(PostgreSQLSequenceQueries.source(hasSequencesCatalog: false) == .sequenceParameters) + } + + @Test("pg_sequences arrived in PostgreSQL 10, not 9.5") + func sequencesCatalogThreshold() { + #expect(!PostgreSQLCapabilities(serverVersion: 90_500).hasSequencesCatalog) + #expect(!PostgreSQLCapabilities(serverVersion: 90_624).hasSequencesCatalog) + #expect(PostgreSQLCapabilities(serverVersion: 100_000).hasSequencesCatalog) + } + + @Test("A sequence the role cannot read reports no parameters instead of failing the listing") + func legacyListingIsPrivilegeGuarded() { + let sql = PostgreSQLSequenceQueries.sequenceList(schema: "public", dependentOnTable: nil, source: .sequenceParameters) + #expect(sql.contains("pg_catalog.has_sequence_privilege(c.oid, 'SELECT,USAGE,UPDATE') AS can_read_parameters")) + #expect(sql.contains("(pg_catalog.pg_sequence_parameters(s.oid)).start_value")) + #expect(sql.contains("pg_catalog.has_sequence_privilege(c.oid, 'SELECT') AS readable")) + #expect(!sql.contains("pg_sequences")) + } + + @Test("A table's sequences are the ones its column defaults depend on, in either source") + func dependencyComesFromPgDepend() { + for source in [PostgreSQLSequenceQueries.Source.sequencesView, .sequenceParameters] { + let sql = PostgreSQLSequenceQueries.sequenceList(schema: "public", dependentOnTable: "orders", source: source) + #expect(sql.contains("d.classid = 'pg_catalog.pg_attrdef'::pg_catalog.regclass")) + #expect(sql.contains("d.refobjid = c.oid")) + #expect(sql.contains("t.relname = 'orders'")) + #expect(!sql.contains("LIKE")) + } + } + + @Test("Each sequence's last value is its own statement, qualified and quoted") + func lastValueIsPerSequence() { + let sql = PostgreSQLSequenceQueries.lastValue(schema: "app", sequence: "we\"ird's") + #expect(sql == "SELECT CASE WHEN is_called THEN last_value END FROM \"app\".\"we\"\"ird's\"") + #expect(!sql.contains("UNION ALL")) + } + + @Test("A server that reports 10 but carries no pg_sequences still lists its sequences") + func missingCatalogFallsBackToParameters() { + #expect(PostgreSQLSequenceQueries.source(hasSequencesCatalog: false) == .sequenceParameters) + #expect(PostgreSQLSequenceQueries.source(hasSequencesCatalog: true) == .sequencesView) + } + + @Test("The pre-10 listing tests each sequence's privileges once, in a derived table the planner cannot flatten") + func privilegesReadOncePerSequence() { + let sql = PostgreSQLSequenceQueries.sequenceList( + schema: "public", dependentOnTable: nil, source: .sequenceParameters + ) + #expect(sql.components(separatedBy: "has_sequence_privilege").count - 1 == 2) + #expect(sql.contains("OFFSET 0")) + #expect(sql.contains("CASE WHEN s.can_read_parameters")) + #expect(!sql.contains("AS parameters")) + } + + @Test("Rows decode with booleans in any of the server's spellings") + func definitionsDecode() { + let rows: [[PluginCellValue]] = [ + [.text("we,ird_seq"), .text("7"), .text("5"), .text("50"), .text("1"), .text("t"), .null, .text("true")], + [.text("hidden"), .null, .null, .null, .null, .null, .null, .text("f")] + ] + let definitions = PostgreSQLSequenceQueries.definitions(from: rows) + #expect(definitions.count == 2) + #expect(definitions[0].cycles) + #expect(definitions[0].needsLastValueRead) + #expect(!definitions[1].cycles) + #expect(!definitions[1].needsLastValueRead) + } + + @Test("A sequence with known parameters restores with them and its position") + func fullDefinition() { + let definition = PostgreSQLSequenceDefinition( + name: "invoice_seq", startValue: "1000", minValue: "1", maxValue: "9223372036854775807", + increment: "5", cycles: false, lastValue: "1005", needsLastValueRead: false + ) + #expect(definition.ddl == """ + CREATE SEQUENCE "invoice_seq" INCREMENT BY 5 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1000; + SELECT pg_catalog.setval('"invoice_seq"', 1005, true); + """) + } + + @Test("A sequence whose parameters the role could not read is still created, with defaults") + func unknownParameters() { + let definition = PostgreSQLSequenceDefinition( + name: "hidden", startValue: nil, minValue: nil, maxValue: nil, increment: nil, + cycles: false, lastValue: nil, needsLastValueRead: false + ) + #expect(definition.ddl == "CREATE SEQUENCE \"hidden\";") + } + + @Test("The setval target is an identifier inside a literal, so a quote in the name cannot break either") + func hostileNameInSetval() { + let definition = PostgreSQLSequenceDefinition( + name: "we\"ird's", startValue: "7", minValue: "5", maxValue: "50", increment: "1", + cycles: true, lastValue: "8", needsLastValueRead: false + ) + #expect(definition.ddl.hasPrefix("CREATE SEQUENCE \"we\"\"ird's\" INCREMENT BY 1 MINVALUE 5 MAXVALUE 50 START WITH 7 CYCLE;")) + #expect(definition.ddl.hasSuffix("SELECT pg_catalog.setval('\"we\"\"ird''s\"', 8, true);")) + } + + @Test("A last value that is not a number is left out rather than interpolated") + func nonNumericLastValue() { + let definition = PostgreSQLSequenceDefinition( + name: "s", startValue: "1", minValue: "1", maxValue: "10", increment: "1", + cycles: false, lastValue: "1); DROP TABLE t; --", needsLastValueRead: false + ) + #expect(!definition.ddl.contains("setval")) + } + + @Test("Reading a last value clears the pending read") + func withLastValue() { + let definition = PostgreSQLSequenceDefinition( + name: "s", startValue: "1", minValue: "1", maxValue: "10", increment: "1", + cycles: false, lastValue: nil, needsLastValueRead: true + ) + let read = definition.withLastValue("4") + #expect(read.lastValue == "4") + #expect(!read.needsLastValueRead) + } +} + +@Suite("PostgreSQL collation and table metadata reads") +struct PostgreSQLCollationAndMetadataQueryTests { + @Test("Before PostgreSQL 10 every collation but the default is a libc one") + func legacyCollations() { + let sql = PostgreSQLSchemaQueries.collationList(capabilities: PostgreSQLCapabilities(serverVersion: 90_624)) + #expect(!sql.contains("collprovider")) + #expect(sql.contains("WHERE oid <> 100")) + let modern = PostgreSQLSchemaQueries.collationList(capabilities: PostgreSQLCapabilities(serverVersion: 170_000)) + #expect(modern.contains("collprovider IN ('b', 'c', 'i')")) + } + + @Test("Table sizes and comments are read by relation oid, so a mixed-case name resolves") + func allTablesMetadataUsesRelid() { + let sql = PostgreSQLSchemaQueries.allTablesMetadata(schema: "app") + #expect(sql.contains("pg_total_relation_size(relid)")) + #expect(sql.contains("obj_description(relid, 'pg_class')")) + #expect(!sql.contains("::regclass")) + #expect(!sql.contains("||'.'||")) + } + + @Test("The schema is quoted as a literal") + func allTablesMetadataQuotesSchema() { + #expect(PostgreSQLSchemaQueries.allTablesMetadata(schema: "o'brien").contains("schemaname = 'o''brien'")) + #expect(PostgreSQLSchemaQueries.allTablesMetadata(schema: "a\\b").contains("schemaname = E'a\\\\b'")) + } +} + +@Suite("PostgreSQL grant reads") +struct PostgreSQLGrantQueryTests { + @Test("aclexplode runs in a subquery's select list, which PostgreSQL 9.1 accepts") + func grantsAvoidLateral() { + let queries = [ + PostgreSQLPrincipalQueries.databaseGrants(role: "reader"), + PostgreSQLPrincipalQueries.schemaGrants(role: "reader"), + PostgreSQLPrincipalQueries.tableGrants(role: "reader"), + PostgreSQLPrincipalQueries.columnGrants(role: "reader") + ] + for sql in queries { + #expect(!sql.contains("LATERAL")) + #expect(sql.contains("pg_catalog.aclexplode(")) + #expect(sql.contains("JOIN pg_roles r ON r.oid = (s.acl).grantee")) + #expect(sql.contains("r.rolname = 'reader'")) + } + } +} + +@Suite("PostgreSQL catalog booleans") +struct PostgreSQLCatalogBooleanTests { + @Test("The driver hands a boolean column over as true or false, and a text cast may say t or f") + func spellings() { + #expect(PostgreSQLCatalogBoolean.isTrue("true")) + #expect(PostgreSQLCatalogBoolean.isTrue("t")) + #expect(PostgreSQLCatalogBoolean.isTrue("TRUE")) + #expect(PostgreSQLCatalogBoolean.isTrue("YES")) + #expect(PostgreSQLCatalogBoolean.isTrue("on")) + #expect(PostgreSQLCatalogBoolean.isTrue("1")) + #expect(!PostgreSQLCatalogBoolean.isTrue("false")) + #expect(!PostgreSQLCatalogBoolean.isTrue("f")) + #expect(!PostgreSQLCatalogBoolean.isTrue("no")) + #expect(!PostgreSQLCatalogBoolean.isTrue("off")) + #expect(!PostgreSQLCatalogBoolean.isTrue("0")) + #expect(!PostgreSQLCatalogBoolean.isTrue("")) + #expect(!PostgreSQLCatalogBoolean.isTrue(nil)) + } + + @Test("A primary key index decoded from the driver's boolean text is unique and primary") + func primaryKeyIndexKeepsItsFlags() throws { + let row: [PluginCellValue] = [ + .text("orders"), .text("orders_pkey"), .text("{id}"), .text("true"), .text("true"), .text("btree"), .null + ] + let decoded = try #require(PostgreSQLIndexRow.index(from: row)) + #expect(decoded.index.isUnique) + #expect(decoded.index.isPrimary) + } +} diff --git a/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift b/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift new file mode 100644 index 0000000000..d9af0f9c66 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift @@ -0,0 +1,215 @@ +// +// PostgreSQLLiteralQuotingTests.swift +// TableProTests +// + +import Foundation +import Testing + +/// Every catalog statement the PostgreSQL-family plugin builds names objects by literal, and a +/// catalog name may legally hold a backslash. With `standard_conforming_strings = off` a backslash +/// inside a plain literal is an escape, so `'a\b'` does not mean `a\b` and `'x\'' OR true--'` ends +/// after `x'` and leaves `OR true` as SQL. Measured on PostgreSQL 17.11: the first returned no rows +/// for a schema holding one table, the second turned a one-row listing into 417 rows, every relation +/// in the database. +/// +/// `PostgreSQLObjectQueries.quoteLiteral` is the single answer, and these cases pin both halves of +/// it: an `E''` string whenever the value holds a backslash, and output byte-identical to plain +/// quote doubling whenever it does not. +@Suite("PostgreSQL literal quoting") +struct PostgreSQLLiteralQuotingTests { + private static let caps = PostgreSQLCapabilities.assumingModernWhenUnknown(170_000) + + /// Every pure builder in the plugin that names an object by literal, called with one schema and + /// one table so a single assertion covers the lot. Redshift and CockroachDB are included: no + /// server for either was available, and a name with no backslash is byte-identical on both, + /// which is what makes that safe. + private static func statements(schema: String, table: String) -> [String] { + [ + PostgreSQLSchemaQueries.fetchTables( + schema: schema, includeMaterializedViews: true, includeForeignTables: true + ), + PostgreSQLSchemaQueries.fetchPartitions(schema: schema, table: table), + PostgreSQLSchemaQueries.columnsQuery( + schema: schema, table: table, capabilities: caps, includeMaterializedViews: true + ), + PostgreSQLSchemaQueries.checkConstraintsQuery(schema: schema, table: table), + PostgreSQLSchemaQueries.allTablesMetadata(schema: schema), + PostgreSQLObjectQueries.routineList(schema: schema, capabilities: caps), + PostgreSQLObjectQueries.triggerList(schema: schema, table: table), + PostgreSQLObjectQueries.routineDefinitionByName(name: table, schema: schema, arguments: nil), + PostgreSQLObjectQueries.userDefinedTypeList(schema: schema, identity: nil, capabilities: caps), + PostgreSQLIndexQueries.indexList(schema: schema, table: table), + PostgreSQLForeignKeyQueries.foreignKeyList(schema: schema, table: table, capabilities: caps), + PostgreSQLSequenceQueries.sequenceList( + schema: schema, dependentOnTable: table, source: .sequencesView + ), + PostgreSQLCatalogForeignKeys.query(schema: schema, table: table, excludesPartitionClones: true), + PostgreSQLPrincipalQueries.tables(schema: schema), + PostgreSQLPrincipalQueries.columns(schema: schema, table: table), + PostgreSQLPrincipalQueries.databaseGrants(role: schema), + PostgreSQLPrincipalQueries.schemaGrants(role: schema), + PostgreSQLPrincipalQueries.tableGrants(role: schema), + PostgreSQLPrincipalQueries.columnGrants(role: schema), + PostgreSQLPrincipalQueries.ownsObjects(role: schema), + PostgreSQLRelationSQL.concurrentRefreshQuery(name: table, schema: schema), + PostgreSQLViewDefinition.catalogQuery(name: table, schema: schema), + RedshiftSchemaQueries.columnsQuery(schema: schema, table: table), + RedshiftExternalSchemaQueries.listExternalTables(schema: schema, database: table), + RedshiftExternalSchemaQueries.listExternalColumns( + schema: schema, table: table, database: table + ), + ColumnQueryShape.primaryKeyJoin( + schema: schema, fragments: ColumnQueryShape.fragments(table: table) + ) + ] + } + + @Test("A backslash in a catalog name is emitted as an E'' literal") + func backslashNamesBecomeEscapeStrings() { + for statement in Self.statements(schema: #"a\b"#, table: #"c\d"#) { + #expect(statement.contains(#"E'a\\b'"#), "No E-string for the schema in: \(statement)") + #expect(!statement.contains(#"'a\b'"#), "A plain literal survived in: \(statement)") + #expect(!statement.contains(#"'c\d'"#), "A plain literal survived in: \(statement)") + } + } + + @Test("A plain name keeps its plain literal") + func plainNamesStayPlain() { + for statement in Self.statements(schema: "s2", table: "orders") { + #expect(statement.contains("'s2'"), "The schema lost its plain literal in: \(statement)") + } + } + + @Test("An apostrophe in a catalog name is doubled and nothing else") + func apostropheNamesAreDoubled() { + for statement in Self.statements(schema: "o'brien", table: "d'ev") { + #expect(statement.contains("'o''brien'"), "The schema lost its doubled quote in: \(statement)") + #expect(!statement.contains("E'o''brien'"), "An apostrophe took an E-string in: \(statement)") + } + } + + /// The blast-radius guard, stated as the property rather than per call site: for any value + /// holding no backslash, `quoteLiteral` emits exactly what plain quote doubling emitted before + /// this change, so the only statements that move are the ones that were wrong. + @Test("A value without a backslash quotes byte-identically to plain quote doubling") + func quotingIsUnchangedWithoutABackslash() { + let values = ["", "public", "s2", "orders", "o'brien", "''", "%wild_card%", "Ünïcødé", "a\tb"] + for value in values { + let doubled = value.replacingOccurrences(of: "'", with: "''") + #expect(PostgreSQLObjectQueries.quoteLiteral(value) == "'\(doubled)'") + } + } + + /// The injection the probe demonstrated. Quote doubling alone wrote + /// `schemaname = 'x\'' OR true--'`, whose literal ends after `x'` under the legacy setting, so + /// `OR true` ran as SQL and the listing returned every relation in the database. + @Test("A quote after a backslash cannot close the literal") + func quoteAfterBackslashCannotEscape() { + let statement = PostgreSQLSchemaQueries.allTablesMetadata(schema: #"x\' OR true--"#) + #expect(statement.contains(#"E'x\\'' OR true--'"#)) + #expect(!statement.contains(#"'x\'' OR true--'"#)) + #expect(statement.components(separatedBy: "OR true").count == 2) + } + + /// The wildcards go on before the quoting, because an `E` prefix cannot be spliced into the + /// middle of a literal. + @Test("A search pattern is wrapped in its wildcards and then quoted once") + func searchPatternIsQuotedAsAWhole() { + #expect(PostgreSQLPrincipalQueries.searchObjects(pattern: "ord", limit: 10).contains("ILIKE '%ord%'")) + #expect( + PostgreSQLPrincipalQueries.searchObjects(pattern: #"a\b"#, limit: 10) + .contains(#"ILIKE E'%a\\b%'"#) + ) + } + + @Test("A NUL is dropped on both arms rather than reaching libpq") + func nulIsStripped() { + #expect(PostgreSQLObjectQueries.quoteLiteral("a\0b") == "'ab'") + #expect(PostgreSQLObjectQueries.quoteLiteral("a\0\\b") == #"E'a\\b'"#) + #expect(!PostgreSQLSchemaQueries.allTablesMetadata(schema: "a\0b").contains("\0")) + } +} + +/// Nothing at runtime can see a statement that wraps its own quotes around an already-complete +/// literal: `''E'a\\b''` is valid SQL that matches nothing, and a plain `'\(name)'` only misbehaves +/// on a server running the legacy setting. So the guard is a source scan, the same shape +/// `IndexDDLOwnershipTests` and `SyncMapperFieldAccessTests` use. +@Suite("PostgreSQL literal quoting source scan") +struct PostgreSQLLiteralQuotingSourceScanTests { + /// `PostgreSQLObjectQueries` owns the quoting and is the one file allowed to write the quotes + /// itself. `LibPQConnectionString` builds a libpq conninfo string, whose quoting rules are + /// libpq's rather than SQL's. + private static let quotingOwners: Set = [ + "PostgreSQLObjectQueries.swift", + "LibPQConnectionString.swift" + ] + + /// `LibPQDriverCore` carries the `escapeStringLiteral` PluginKit requirement, whose contract is + /// inner text for the app and the export plugins to wrap in their own quotes, and + /// `LibPQStringConformance` implements it. Both read the session's reported + /// `standard_conforming_strings`, so neither may be reached from a statement builder. + private static let escapeHelperOwners: Set = [ + "LibPQDriverCore.swift", + "LibPQStringConformance.swift" + ] + + private static let pluginDirectory: URL? = { + var directory = URL(fileURLWithPath: #filePath) + for _ in 0..<3 { directory.deleteLastPathComponent() } + let plugin = directory + .appendingPathComponent("Plugins") + .appendingPathComponent("PostgreSQLDriverPlugin") + return FileManager.default.fileExists(atPath: plugin.path) ? plugin : nil + }() + + private static func sources() throws -> [(name: String, text: String)] { + guard let pluginDirectory else { return [] } + let files = try FileManager.default.contentsOfDirectory( + at: pluginDirectory, includingPropertiesForKeys: nil) + return try files + .filter { $0.pathExtension == "swift" } + .map { ($0.lastPathComponent, try String(contentsOf: $0, encoding: .utf8)) } + } + + @Test("The scan reaches the plugin sources at all") + func sourcesAreReachable() throws { + let sources = try Self.sources() + #expect(!sources.isEmpty, "No PostgreSQL plugin sources found; the guards below would pass vacuously") + #expect(sources.contains { $0.name == "PostgreSQLObjectQueries.swift" }) + #expect(Self.escapeHelperOwners.isSubset(of: Set(sources.map(\.name)))) + } + + /// A line carrying `message:` is a Swift diagnostic quoting a name for the reader, not SQL. + @Test("No file builds a SQL literal by hand") + func noHandWrittenLiterals() throws { + var offenders: [String] = [] + for source in try Self.sources() where !Self.quotingOwners.contains(source.name) { + for (offset, line) in source.text.components(separatedBy: "\n").enumerated() + where line.contains(#"'\("#) && !line.contains("message:") { + offenders.append("\(source.name):\(offset + 1)") + } + } + + #expect( + offenders.isEmpty, + "These lines quote a literal themselves instead of calling quoteLiteral: \(offenders)" + ) + } + + @Test("No file builds catalog SQL through a session-dependent escape helper") + func noSessionDependentEscapeHelpers() throws { + let helpers = ["escapeLiteral(", "escapeStringLiteral(", "LibPQStringConformance.escape("] + var offenders: [String] = [] + for source in try Self.sources() where !Self.escapeHelperOwners.contains(source.name) { + for helper in helpers where source.text.contains(helper) { + offenders.append("\(source.name): \(helper)") + } + } + + #expect( + offenders.isEmpty, + "These files reach for an escape helper instead of quoteLiteral: \(offenders)" + ) + } +} diff --git a/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift b/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift index 87956a55b1..903e6f0c14 100644 --- a/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift +++ b/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift @@ -6,7 +6,7 @@ import Testing struct PostgreSQLPartitionFilterTests { private func awareQuery() -> String { PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: false, includeForeignTables: false ) @@ -42,7 +42,7 @@ struct PostgreSQLPartitionFilterTests { @Test("Partition awareness degrades independently of the optional catalogs") func partitionAwarenessDegradesIndependently() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: true, includeForeignTables: true, includePartitionAwareness: false @@ -56,7 +56,7 @@ struct PostgreSQLPartitionFilterTests { @Test("Every union branch still projects three aligned columns when partition aware") func unionBranchesStayAligned() { let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: "public", + schema: "public", includeMaterializedViews: true, includeForeignTables: true ) @@ -69,7 +69,7 @@ struct PostgreSQLPartitionFilterTests { @Test("Partition listing is scoped to one parent in one schema") func fetchPartitionsScopesToParent() { - let query = PostgreSQLSchemaQueries.fetchPartitions(schemaLiteral: "public", tableLiteral: "orders") + let query = PostgreSQLSchemaQueries.fetchPartitions(schema: "public", table: "orders") #expect(query.contains("pn.nspname = 'public'")) #expect(query.contains("parent.relname = 'orders'")) #expect(query.contains("parent.relkind = 'p'")) @@ -77,13 +77,13 @@ struct PostgreSQLPartitionFilterTests { @Test("Partition listing sorts the DEFAULT partition last") func fetchPartitionsSortsDefaultLast() { - let query = PostgreSQLSchemaQueries.fetchPartitions(schemaLiteral: "public", tableLiteral: "orders") + let query = PostgreSQLSchemaQueries.fetchPartitions(schema: "public", table: "orders") #expect(query.contains("ORDER BY pg_catalog.pg_get_expr(cc.relpartbound, cc.oid) = 'DEFAULT', cc.relname")) } @Test("Partition listing projects relkind so subpartitioned children stay expandable") func fetchPartitionsProjectsRelkind() { - let query = PostgreSQLSchemaQueries.fetchPartitions(schemaLiteral: "public", tableLiteral: "orders") + let query = PostgreSQLSchemaQueries.fetchPartitions(schema: "public", table: "orders") #expect(query.contains("SELECT cc.relname, cc.relkind")) } } diff --git a/TableProTests/Plugins/PostgreSQLRelationSQLTests.swift b/TableProTests/Plugins/PostgreSQLRelationSQLTests.swift new file mode 100644 index 0000000000..220fe8b167 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLRelationSQLTests.swift @@ -0,0 +1,131 @@ +// +// PostgreSQLRelationSQLTests.swift +// TableProTests +// +// Tests for PostgreSQLRelationSQL (compiled via project.yml from PostgreSQLDriverPlugin). The +// expectations come from PostgreSQL 17.11: COMMENT ON checks its keyword against the relation's +// relkind, and CONCURRENTLY is refused for a view with no usable unique index or no rows. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQL relation statements") +struct PostgreSQLRelationSQLTests { + // MARK: - Comments + + @Test("The COMMENT keyword follows the object kind", arguments: [ + ("TABLE", "TABLE"), + ("PARTITIONED TABLE", "TABLE"), + ("VIEW", "VIEW"), + ("MATERIALIZED VIEW", "MATERIALIZED VIEW"), + ("FOREIGN TABLE", "FOREIGN TABLE") + ]) + func commentKeywordFollowsKind(objectType: String, keyword: String) { + #expect(PostgreSQLRelationSQL.commentKeyword(forObjectType: objectType) == keyword) + } + + /// `COMMENT ON TABLE` on a view fails with "is not a table", so a kind with no keyword of its + /// own produces no statement rather than one the server refuses. + @Test("A kind PostgreSQL cannot comment on produces no statement", arguments: [ + "SYSTEM TABLE", "EXTERNAL TABLE", "SEQUENCE", "" + ]) + func unsupportedKindsProduceNoStatement(objectType: String) { + #expect(PostgreSQLRelationSQL.commentKeyword(forObjectType: objectType) == nil) + #expect(PostgreSQLRelationSQL.commentStatement( + name: "t", schema: "s", objectType: objectType, comment: "x" + ) == nil) + } + + @Test("A comment statement qualifies and quotes the object") + func commentStatementQualifies() { + let sql = PostgreSQLRelationSQL.commentStatement( + name: "Mat \"View\" X", + schema: "My \"Odd\" Schema", + objectType: "MATERIALIZED VIEW", + comment: "it's here" + ) + + #expect(sql == "COMMENT ON MATERIALIZED VIEW \"My \"\"Odd\"\" Schema\".\"Mat \"\"View\"\" X\" IS 'it''s here'") + } + + /// Quote doubling alone is injectable when `standard_conforming_strings` is off, where a + /// trailing backslash eats the closing quote. A value holding one is written as an E'' string. + @Test("A comment with a backslash is written as an E-string") + func backslashCommentUsesEString() { + let sql = PostgreSQLRelationSQL.commentStatement( + name: "t", schema: "s", objectType: "TABLE", comment: #"Path C:\temp\"# + ) + + #expect(sql == #"COMMENT ON TABLE "s"."t" IS E'Path C:\\temp\\'"#) + } + + @Test("An empty or missing comment clears it") + func emptyCommentClears() { + #expect(PostgreSQLRelationSQL.commentValue(nil) == "NULL") + #expect(PostgreSQLRelationSQL.commentValue("") == "NULL") + #expect(PostgreSQLRelationSQL.commentStatement( + name: "t", schema: "s", objectType: "TABLE", comment: nil + ) == "COMMENT ON TABLE \"s\".\"t\" IS NULL") + } + + /// Whitespace is a value here. Deciding that a field holding only spaces means "remove the + /// comment" belongs to the sheet, which normalizes before asking for a statement. + @Test("Whitespace is quoted rather than treated as a clear") + func whitespaceCommentIsStored() { + #expect(PostgreSQLRelationSQL.commentValue(" ") == "' '") + } + + // MARK: - Refresh + + @Test("Refresh qualifies the view and adds CONCURRENTLY only when asked") + func refreshStatementShape() { + #expect(PostgreSQLRelationSQL.refreshStatement(name: "mv", schema: "sales", concurrently: false) + == "REFRESH MATERIALIZED VIEW \"sales\".\"mv\"") + #expect(PostgreSQLRelationSQL.refreshStatement(name: "mv", schema: "sales", concurrently: true) + == "REFRESH MATERIALIZED VIEW CONCURRENTLY \"sales\".\"mv\"") + } + + @Test("Refresh quotes a name that needs it") + func refreshQuotesNames() { + #expect(PostgreSQLRelationSQL.refreshStatement( + name: "MV \"Totals\"", schema: "Sales Q1", concurrently: false + ) == "REFRESH MATERIALIZED VIEW \"Sales Q1\".\"MV \"\"Totals\"\"\"") + } + + /// The predicate measured against every index shape on PostgreSQL 17.11: + /// `scripts/check-postgres-matview-refresh.sh` re-runs that comparison against a live server. + @Test("The eligibility query tests exactly what the server requires") + func eligibilityQueryPredicate() { + let sql = PostgreSQLRelationSQL.concurrentRefreshQuery(name: "mv", schema: "sales") + + #expect(sql.contains("c.relispopulated")) + #expect(sql.contains("i.indisunique")) + #expect(sql.contains("i.indimmediate")) + #expect(sql.contains("i.indisvalid")) + #expect(sql.contains("i.indpred IS NULL")) + #expect(sql.contains("i.indexprs IS NULL")) + #expect(sql.contains("c.relkind = 'm'")) + #expect(sql.contains("n.nspname = 'sales'")) + #expect(sql.contains("c.relname = 'mv'")) + } + + /// An unpopulated view is refused whatever its indexes are, and the fix for it is a plain + /// refresh rather than a new index, so population is reported first. + @Test("Population is reported before the index requirement") + func populationTakesPrecedence() { + #expect(PostgreSQLRelationSQL.concurrentRefreshAvailability( + isPopulated: false, hasUsableUniqueIndex: true + ) == .requiresPopulatedView) + #expect(PostgreSQLRelationSQL.concurrentRefreshAvailability( + isPopulated: false, hasUsableUniqueIndex: false + ) == .requiresPopulatedView) + #expect(PostgreSQLRelationSQL.concurrentRefreshAvailability( + isPopulated: true, hasUsableUniqueIndex: false + ) == .requiresUniqueIndex) + #expect(PostgreSQLRelationSQL.concurrentRefreshAvailability( + isPopulated: true, hasUsableUniqueIndex: true + ) == .available) + } +} diff --git a/TableProTests/Plugins/PostgreSQLTypeDefinitionTests.swift b/TableProTests/Plugins/PostgreSQLTypeDefinitionTests.swift index e2b042486a..2c0da383df 100644 --- a/TableProTests/Plugins/PostgreSQLTypeDefinitionTests.swift +++ b/TableProTests/Plugins/PostgreSQLTypeDefinitionTests.swift @@ -193,7 +193,7 @@ struct PostgreSQLTypeDefinitionTests { #expect(ddl.hasPrefix("CREATE TYPE \"my schema\".\"Weird \"\"Name\"\"\" AS ENUM (")) } - @Test("A catalog row parses by projection position, JSON columns included") + @Test("A catalog row parses by projection position, array columns included") func rowParsing() throws { var row = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) row[PostgreSQLTypeDefinition.Column.identity.rawValue] = .text("16397") @@ -206,8 +206,9 @@ struct PostgreSQLTypeDefinitionTests { row[PostgreSQLTypeDefinition.Column.collation.rawValue] = .text("pg_catalog.\"C\"") row[PostgreSQLTypeDefinition.Column.isNotNull.rawValue] = .text("true") row[PostgreSQLTypeDefinition.Column.defaultValue.rawValue] = .text("'x'::text") - row[PostgreSQLTypeDefinition.Column.constraints.rawValue] = .text( - #"[{"name" : "email_check", "definition" : "CHECK ((VALUE ~ '@'::text))"}]"# + row[PostgreSQLTypeDefinition.Column.constraintNames.rawValue] = .text("{email_check}") + row[PostgreSQLTypeDefinition.Column.constraintDefinitions.rawValue] = .text( + #"{"CHECK ((VALUE ~ '@'::text))"}"# ) let parsed = try #require(PostgreSQLTypeDefinition.record(from: row)) @@ -222,23 +223,23 @@ struct PostgreSQLTypeDefinitionTests { #expect(parsed.comment == "Mail") } - @Test("Enum labels and composite fields parse from their JSON aggregates in order") - func jsonAggregatesParse() throws { + @Test("Enum labels and composite fields parse from their array aggregates in order") + func arrayAggregatesParse() throws { var enumRow = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) enumRow[PostgreSQLTypeDefinition.Column.identity.rawValue] = .text("1") enumRow[PostgreSQLTypeDefinition.Column.name.rawValue] = .text("mood") enumRow[PostgreSQLTypeDefinition.Column.schema.rawValue] = .text("app") enumRow[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("e") - enumRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .text(#"["sad", "ok", "it's"]"#) + enumRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .text("{sad,ok,it's}") let parsedEnum = try #require(PostgreSQLTypeDefinition.record(from: enumRow)) #expect(parsedEnum.enumLabels == ["sad", "ok", "it's"]) var compositeRow = enumRow compositeRow[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("c") compositeRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .null - compositeRow[PostgreSQLTypeDefinition.Column.fields.rawValue] = .text( - #"[{"name" : "x", "type" : "text", "collation" : "pg_catalog.\"C\""}, {"name" : "y", "type" : "integer", "collation" : null}]"# - ) + compositeRow[PostgreSQLTypeDefinition.Column.fieldNames.rawValue] = .text("{x,y}") + compositeRow[PostgreSQLTypeDefinition.Column.fieldTypes.rawValue] = .text("{text,integer}") + compositeRow[PostgreSQLTypeDefinition.Column.fieldCollations.rawValue] = .text(#"{"pg_catalog.\"C\"",NULL}"#) compositeRow[PostgreSQLTypeDefinition.Column.spelling.rawValue] = .text("app.\"Mood\"") let parsedComposite = try #require(PostgreSQLTypeDefinition.record(from: compositeRow)) #expect(parsedComposite.fields == [ @@ -249,6 +250,60 @@ struct PostgreSQLTypeDefinitionTests { #expect(PostgreSQLTypeDefinition.info(from: parsedComposite).columnTypeSpelling == "app.\"Mood\"") } + @Test("Labels, field names and constraint names survive the characters an array literal quotes") + func hostileNamesSurviveArrayDecoding() throws { + var enumRow = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) + enumRow[PostgreSQLTypeDefinition.Column.identity.rawValue] = .text("1") + enumRow[PostgreSQLTypeDefinition.Column.name.rawValue] = .text("lbl") + enumRow[PostgreSQLTypeDefinition.Column.schema.rawValue] = .text("public") + enumRow[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("e") + enumRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .text( + #"{"very happy","sad,ish","NULL","q\"t","back\\\\slash",""}"# + ) + let parsedEnum = try #require(PostgreSQLTypeDefinition.record(from: enumRow)) + #expect(parsedEnum.enumLabels == ["very happy", "sad,ish", "NULL", "q\"t", "back\\\\slash", ""]) + + var compositeRow = enumRow + compositeRow[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("c") + compositeRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .null + compositeRow[PostgreSQLTypeDefinition.Column.fieldNames.rawValue] = .text(#"{"first name","q\"t","NULL"}"#) + compositeRow[PostgreSQLTypeDefinition.Column.fieldTypes.rawValue] = .text("{text,integer,text}") + compositeRow[PostgreSQLTypeDefinition.Column.fieldCollations.rawValue] = .text(#"{"pg_catalog.\"C\"",NULL,NULL}"#) + let parsedComposite = try #require(PostgreSQLTypeDefinition.record(from: compositeRow)) + #expect(parsedComposite.fields == [ + PluginUserDefinedTypeField(name: "first name", type: "text", collation: "pg_catalog.\"C\""), + PluginUserDefinedTypeField(name: "q\"t", type: "integer"), + PluginUserDefinedTypeField(name: "NULL", type: "text") + ]) + + var domainRow = enumRow + domainRow[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("d") + domainRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .null + domainRow[PostgreSQLTypeDefinition.Column.baseType.rawValue] = .text("text") + domainRow[PostgreSQLTypeDefinition.Column.constraintNames.rawValue] = .text(#"{b_len,"has,comma"}"#) + domainRow[PostgreSQLTypeDefinition.Column.constraintDefinitions.rawValue] = .text( + #"{"CHECK ((length(VALUE) < 10))","CHECK ((VALUE <> ''::text))"}"# + ) + let parsedDomain = try #require(PostgreSQLTypeDefinition.record(from: domainRow)) + #expect(parsedDomain.constraints == [ + PostgreSQLDomainConstraint(name: "b_len", definition: "CHECK ((length(VALUE) < 10))"), + PostgreSQLDomainConstraint(name: "has,comma", definition: "CHECK ((VALUE <> ''::text))") + ]) + } + + @Test("A type with no fields, labels or constraints reads NULL aggregates as empty") + func nullAggregatesAreEmpty() throws { + var row = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) + row[PostgreSQLTypeDefinition.Column.identity.rawValue] = .text("1") + row[PostgreSQLTypeDefinition.Column.name.rawValue] = .text("empty") + row[PostgreSQLTypeDefinition.Column.schema.rawValue] = .text("public") + row[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("c") + let parsed = try #require(PostgreSQLTypeDefinition.record(from: row)) + #expect(parsed.fields.isEmpty) + #expect(parsed.enumLabels.isEmpty) + #expect(parsed.constraints.isEmpty) + } + @Test("A row of an unknown kind is skipped rather than mislabelled") func unknownKindIsSkipped() { var row = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) diff --git a/TableProTests/Plugins/PostgreSQLTypeQueryTests.swift b/TableProTests/Plugins/PostgreSQLTypeQueryTests.swift index 7573b9a321..3fa93767a4 100644 --- a/TableProTests/Plugins/PostgreSQLTypeQueryTests.swift +++ b/TableProTests/Plugins/PostgreSQLTypeQueryTests.swift @@ -15,7 +15,7 @@ import Testing struct PostgreSQLTypeQueryTests { @Test("The listing reads pg_type for enums, composites, domains and ranges in one schema") func listReadsPgType() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(170_000)) #expect(sql.contains("FROM pg_catalog.pg_type t")) #expect(sql.contains("t.typtype IN ('e', 'c', 'd', 'r')")) #expect(sql.contains("AND n.nspname = 'app'")) @@ -26,7 +26,7 @@ struct PostgreSQLTypeQueryTests { /// the extension. Neither is a type the user created. @Test("Table row types and extension members are excluded") func excludesRowTypesAndExtensionMembers() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(170_000)) #expect(sql.contains("(t.typtype <> 'c' OR c.relkind = 'c')")) #expect(sql.contains("d.deptype = 'e'")) } @@ -35,16 +35,17 @@ struct PostgreSQLTypeQueryTests { /// would state the same thing twice in the definition. @Test("Only CHECK constraints are collected for a domain") func domainConstraintsAreChecksOnly() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(170_000)) #expect(sql.contains("con.contype = 'c'")) } @Test("The projection matches the parser's column order") func projectionOrderMatchesParser() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(170_000)) let aliases = [ "AS identity", "AS name", "AS schema", "AS kind", "AS owner", "AS comment", "AS enum_labels", - "AS fields", "AS base_type", "AS collation", "AS not_null", "AS default_value", "AS constraints", + "AS field_names", "AS field_types", "AS field_collations", "AS base_type", "AS collation", + "AS not_null", "AS default_value", "AS constraint_names", "AS constraint_definitions", "AS range_subtype", "AS range_canonical", "AS range_subtype_diff", "AS range_opclass", "AS range_collation", "AS range_multirange", "AS spelling" ] @@ -63,7 +64,7 @@ struct PostgreSQLTypeQueryTests { @Test("A reload addresses one oid and nothing else") func identityPredicate() { let sql = PostgreSQLObjectQueries.userDefinedTypeList( - schema: nil, identity: "16387", serverVersionNumber: 170_000 + schema: nil, identity: "16387", capabilities: .assumingModernWhenUnknown(170_000) ) #expect(sql.contains("AND t.oid = 16387::oid")) #expect(!sql.contains("n.nspname =")) @@ -72,7 +73,7 @@ struct PostgreSQLTypeQueryTests { @Test("A listing escapes the schema literal") func schemaLiteral() { let sql = PostgreSQLObjectQueries.userDefinedTypeList( - schema: "o'brien", identity: nil, serverVersionNumber: 170_000 + schema: "o'brien", identity: nil, capabilities: .assumingModernWhenUnknown(170_000) ) #expect(sql.contains("AND n.nspname = 'o''brien'")) #expect(!sql.contains("AND t.oid = ")) @@ -82,16 +83,16 @@ struct PostgreSQLTypeQueryTests { /// knows its own reserved words and folding, is what quotes it. @Test("The listing carries the server's own quoted, qualified spelling of each type") func listingCarriesSpelling() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(170_000)) #expect(sql.contains("pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.typname) AS spelling")) } @Test("A server before 14 has no multirange to report") func legacyServerSkipsMultirange() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 130_000) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(130_000)) #expect(sql.contains("NULL::text AS range_multirange")) #expect(!sql.contains("rngmultitypid")) - let modern = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 140_000) + let modern = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(140_000)) #expect(modern.contains("rngmultitypid")) } @@ -100,7 +101,7 @@ struct PostgreSQLTypeQueryTests { @Test("A non-numeric identity is ignored rather than interpolated") func nonNumericIdentityIsIgnored() { let sql = PostgreSQLObjectQueries.userDefinedTypeList( - schema: "app", identity: "1 OR 1=1", serverVersionNumber: 170_000 + schema: "app", identity: "1 OR 1=1", capabilities: .assumingModernWhenUnknown(170_000) ) #expect(!sql.contains("AND t.oid = ")) #expect(!sql.contains("OR 1=1")) @@ -115,9 +116,11 @@ struct PostgreSQLTypeQueryTests { #expect(PostgreSQLObjectQueries.quoteLiteral("it's") == "'it''s'") #expect(PostgreSQLObjectQueries.quoteLiteral("back\\slash") == "E'back\\\\slash'") #expect(PostgreSQLObjectQueries.quoteLiteral("\\'; DROP TYPE x; --") == "E'\\\\''; DROP TYPE x; --'") + #expect(PostgreSQLObjectQueries.quoteLiteral("a\0b") == "'ab'") + #expect(PostgreSQLObjectQueries.quoteLiteral("a\0\\b") == "E'a\\\\b'") let sql = PostgreSQLObjectQueries.userDefinedTypeList( - schema: "a\\'b", identity: nil, serverVersionNumber: 170_000 + schema: "a\\'b", identity: nil, capabilities: .assumingModernWhenUnknown(170_000) ) #expect(sql.contains("AND n.nspname = E'a\\\\''b'")) #expect( @@ -129,26 +132,41 @@ struct PostgreSQLTypeQueryTests { @Test("A server before 9.2 has no pg_range and lists no ranges") func legacyServerSkipsRanges() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 90_100) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(90_100)) #expect(!sql.contains("pg_range")) #expect(sql.contains("t.typtype IN ('e', 'c', 'd')")) #expect(sql.contains("NULL::text AS range_subtype")) } - @Test("A server before 9.4 has no json_build_object and reports no fields or constraints") - func legacyServerSkipsJsonObjects() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 90_300) - #expect(!sql.contains("json_build_object")) - #expect(sql.contains("NULL::text AS fields")) + @Test("A server before 9.4 still reports composite fields and domain CHECK constraints") + func legacyServerReportsFieldsAndConstraints() { + for version: Int32 in [90_124, 90_300] { + let sql = PostgreSQLObjectQueries.userDefinedTypeList( + schema: "app", identity: nil, capabilities: PostgreSQLCapabilities(serverVersion: version) + ) + #expect(!sql.contains("NULL::text AS field_names")) + #expect(sql.contains("array_agg(a.attname ORDER BY a.attnum)")) + #expect(sql.contains("array_agg(pg_catalog.pg_get_constraintdef(con.oid) ORDER BY con.conname)")) + } + } + + @Test("No server version reads a JSON function or type, which 9.1 does not have") + func listingNeverUsesJson() { + for version: Int32 in [90_124, 90_300, 90_400, 170_000] { + let sql = PostgreSQLObjectQueries.userDefinedTypeList( + schema: "app", identity: nil, capabilities: PostgreSQLCapabilities(serverVersion: version) + ) + #expect(!sql.lowercased().contains("json")) + } } /// libpq answers 0 for a handle it has not connected, and reading that as ancient would emit /// the legacy projection on every current server. @Test("An unknown server version reads as modern") func unknownVersionIsModern() { - let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 0) + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, capabilities: .assumingModernWhenUnknown(0)) #expect(sql.contains("pg_range")) - #expect(sql.contains("json_build_object")) + #expect(sql.contains("rngmultitypid")) } @Test("Adding a label appends by default and places beside a neighbour when asked") diff --git a/TableProTests/Plugins/PostgreSQLVersionedStatementsTests.swift b/TableProTests/Plugins/PostgreSQLVersionedStatementsTests.swift new file mode 100644 index 0000000000..eb5896a20e --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLVersionedStatementsTests.swift @@ -0,0 +1,299 @@ +// +// PostgreSQLVersionedStatementsTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQLVersionedStatements") +struct PostgreSQLVersionedStatementsTests { + private static let v91 = PostgreSQLCapabilities(serverVersion: 90_124) + private static let v92 = PostgreSQLCapabilities(serverVersion: 90_223) + private static let v96 = PostgreSQLCapabilities(serverVersion: 90_600) + private static let v10 = PostgreSQLCapabilities(serverVersion: 100_000) + private static let v11 = PostgreSQLCapabilities(serverVersion: 110_000) + private static let v12 = PostgreSQLCapabilities(serverVersion: 120_000) + private static let v13 = PostgreSQLCapabilities(serverVersion: 130_000) + private static let v14 = PostgreSQLCapabilities(serverVersion: 140_000) + private static let v15 = PostgreSQLCapabilities(serverVersion: 150_000) + private static let v16 = PostgreSQLCapabilities(serverVersion: 160_000) + private static let v17 = PostgreSQLCapabilities(serverVersion: 170_000) + + @Test("DDL thresholds match the versions measured against live servers") + func thresholds() { + #expect(!Self.v91.hasRenameConstraint) + #expect(Self.v92.hasRenameConstraint) + #expect(!Self.v92.hasCreateSchemaIfNotExists) + #expect(PostgreSQLCapabilities(serverVersion: 90_300).hasCreateSchemaIfNotExists) + #expect(!PostgreSQLCapabilities(serverVersion: 90_426).hasBrinIndexes) + #expect(PostgreSQLCapabilities(serverVersion: 90_500).hasBrinIndexes) + #expect(!Self.v10.hasExecuteFunctionTriggerSyntax) + #expect(Self.v11.hasExecuteFunctionTriggerSyntax) + #expect(!Self.v11.hasReindexConcurrently) + #expect(Self.v12.hasReindexConcurrently) + #expect(!Self.v13.hasCreateOrReplaceTrigger) + #expect(Self.v14.hasCreateOrReplaceTrigger) + #expect(!Self.v15.hasUnnamedReindexDatabase) + #expect(Self.v16.hasUnnamedReindexDatabase) + } + + @Test("An unknown version takes the conservative form of every gated statement") + func unknownVersionIsConservative() { + let unknown = PostgreSQLCapabilities(serverVersion: 0) + #expect(!unknown.hasBypassRLS) + #expect(!unknown.hasIdentityColumns) + #expect(!unknown.hasGeneratedColumns) + #expect(!unknown.hasSetGeneratedExpression) + #expect(!unknown.hasVirtualGeneratedColumns) + #expect(!unknown.hasDatabaseICULocale) + #expect(!unknown.hasModernICUSyntax) + #expect(PostgreSQLVersionedStatements.createSchema("s", capabilities: unknown).hasPrefix("DO ")) + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: "app", capabilities: unknown) + == "REINDEX DATABASE \"app\"") + let template = PostgreSQLVersionedStatements.triggerTemplate( + qualifiedTable: "t", qualifiedFunction: "f", capabilities: unknown + ) + #expect(template.contains("\nCREATE TRIGGER \"trigger_name\"")) + #expect(template.contains("EXECUTE PROCEDURE f();")) + #expect(PostgreSQLVersionedStatements.copyRows(into: "t", from: "s", columnList: "a", capabilities: unknown) + == "INSERT INTO t (a) SELECT a FROM s") + #expect(!PostgreSQLVersionedStatements.roleAttributes(capabilities: unknown).contains(.bypassrls)) + } + + @Test("The session probe row parses into a database and a version") + func sessionFactsParsing() { + let facts = PostgreSQLSessionFacts(probeRow: ["app", "90124"]) + #expect(facts == PostgreSQLSessionFacts(database: "app", serverVersion: 90_124)) + #expect(PostgreSQLSessionFacts(probeRow: ["app", " 170011 "]).serverVersion == 170_011) + #expect(PostgreSQLSessionFacts(probeRow: ["", "0"]) == .unknown) + #expect(PostgreSQLSessionFacts(probeRow: [nil, "9.1.24"]) == .unknown) + #expect(PostgreSQLSessionFacts(probeRow: []) == .unknown) + } + + @Test("libpq's own version wins; the probed one fills in only when libpq reports none") + func resolvedServerVersion() { + let facts = PostgreSQLSessionFacts(database: "app", serverVersion: 90_124) + #expect(facts.resolvedServerVersion(reported: 170_011) == 170_011) + #expect(facts.resolvedServerVersion(reported: 0) == 90_124) + #expect(PostgreSQLSessionFacts.unknown.resolvedServerVersion(reported: 0) == 0) + } + + @Test("9.3 and later create a schema with IF NOT EXISTS") + func createSchemaModern() { + let statement = PostgreSQLVersionedStatements.createSchema("sales", capabilities: Self.v96) + #expect(statement == "CREATE SCHEMA IF NOT EXISTS \"sales\"") + } + + @Test("Before 9.3 a schema is created from a DO block guarded by pg_namespace") + func createSchemaLegacy() { + let statement = PostgreSQLVersionedStatements.createSchema("sales", capabilities: Self.v91) + #expect(statement.hasPrefix("DO $tablepro$ ")) + #expect(statement.hasSuffix(" $tablepro$")) + #expect(statement.contains("WHERE nspname = 'sales'")) + #expect(statement.contains("EXECUTE 'CREATE SCHEMA \"sales\"'")) + #expect(!statement.contains("IF NOT EXISTS \"")) + } + + @Test("A legacy schema name with quotes and backslashes stays inside its literals") + func createSchemaLegacyEscapesName() { + let statement = PostgreSQLVersionedStatements.createSchema(#"o'b\"x"#, capabilities: Self.v91) + #expect(statement.contains(#"nspname = E'o''b\\"x'"#)) + #expect(statement.contains(#"EXECUTE E'CREATE SCHEMA "o''b\\""x"'"#)) + } + + @Test("A name that contains the dollar-quote tag gets a tag it cannot close") + func createSchemaLegacyAvoidsTagCollision() { + let statement = PostgreSQLVersionedStatements.createSchema("x$tablepro$y", capabilities: Self.v91) + #expect(statement.hasPrefix("DO $tablepro_$ ")) + #expect(statement.hasSuffix(" $tablepro_$")) + } + + @Test("REINDEX DATABASE names the current database where the server requires it") + func reindexDatabase() { + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: "app", capabilities: Self.v91) + == "REINDEX DATABASE \"app\"") + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: "app", capabilities: Self.v11) + == "REINDEX DATABASE \"app\"") + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: "app", capabilities: Self.v12) + == "REINDEX DATABASE CONCURRENTLY \"app\"") + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: "app", capabilities: Self.v15) + == "REINDEX DATABASE CONCURRENTLY \"app\"") + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: "app", capabilities: Self.v16) + == "REINDEX DATABASE CONCURRENTLY") + } + + @Test("Without a known database a pre-16 server gets no REINDEX DATABASE at all") + func reindexDatabaseWithoutName() { + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: nil, capabilities: Self.v12) == nil) + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: "", capabilities: Self.v91) == nil) + #expect(PostgreSQLVersionedStatements.reindexDatabase(currentDatabase: nil, capabilities: Self.v17) + == "REINDEX DATABASE CONCURRENTLY") + } + + @Test("The new-trigger template uses the syntax each server accepts") + func triggerTemplate() { + func template(_ capabilities: PostgreSQLCapabilities) -> String { + PostgreSQLVersionedStatements.triggerTemplate( + qualifiedTable: "\"public\".\"t\"", + qualifiedFunction: "\"public\".\"trigger_function\"", + capabilities: capabilities + ) + } + let legacy = template(Self.v91) + #expect(legacy.contains("\nCREATE TRIGGER \"trigger_name\"")) + #expect(legacy.contains("EXECUTE PROCEDURE \"public\".\"trigger_function\"();")) + #expect(!legacy.contains("OR REPLACE TRIGGER")) + #expect(!legacy.contains("DROP TRIGGER")) + + let eleven = template(Self.v11) + #expect(eleven.contains("EXECUTE FUNCTION")) + #expect(!eleven.contains("DROP TRIGGER")) + + let thirteen = template(Self.v13) + #expect(!thirteen.contains("OR REPLACE TRIGGER")) + + let modern = template(Self.v14) + #expect(modern.contains("CREATE OR REPLACE TRIGGER \"trigger_name\"")) + #expect(modern.contains("EXECUTE FUNCTION")) + #expect(!modern.contains("DROP TRIGGER")) + #expect(modern.hasPrefix("CREATE OR REPLACE FUNCTION \"public\".\"trigger_function\"()")) + } + + @Test("Before 14 an edited trigger keeps its plain CREATE TRIGGER, since the app drops it first") + func editableTriggerLegacy() { + let definition = PostgreSQLVersionedStatements.editableTriggerDefinition( + functionDefinition: "CREATE OR REPLACE FUNCTION f() RETURNS trigger AS $$ BEGIN RETURN NEW; END $$", + triggerDefinition: "CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW EXECUTE PROCEDURE f()", + dropStatement: "DROP TRIGGER IF EXISTS \"t\" ON \"public\".\"x\"", + capabilities: Self.v13 + ) + #expect(definition.hasSuffix("\n\nCREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW EXECUTE PROCEDURE f();")) + #expect(!definition.contains("OR REPLACE TRIGGER")) + #expect(!definition.contains("DROP TRIGGER")) + } + + @Test("From 14 an edited trigger is replaced in place") + func editableTriggerModern() { + let definition = PostgreSQLVersionedStatements.editableTriggerDefinition( + functionDefinition: "CREATE OR REPLACE FUNCTION f()", + triggerDefinition: "CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW EXECUTE FUNCTION f()", + dropStatement: "DROP TRIGGER IF EXISTS \"t\" ON \"public\".\"x\"", + capabilities: Self.v14 + ) + #expect(definition == "CREATE OR REPLACE FUNCTION f();\n\nCREATE OR REPLACE TRIGGER t BEFORE INSERT ON x FOR EACH ROW EXECUTE FUNCTION f();") + } + + @Test("A constraint trigger has no OR REPLACE form, so it is dropped and recreated") + func editableConstraintTrigger() { + let definition = PostgreSQLVersionedStatements.editableTriggerDefinition( + functionDefinition: "CREATE OR REPLACE FUNCTION f()", + triggerDefinition: "CREATE CONSTRAINT TRIGGER t AFTER INSERT ON x FOR EACH ROW EXECUTE FUNCTION f()", + dropStatement: "DROP TRIGGER IF EXISTS \"t\" ON \"public\".\"x\"", + capabilities: Self.v17 + ) + #expect(definition.contains("DROP TRIGGER IF EXISTS \"t\" ON \"public\".\"x\";\nCREATE CONSTRAINT TRIGGER t")) + } + + @Test("RENAME CONSTRAINT exists from 9.2; on 9.1 the rename falls back to drop and re-add") + func renameConstraint() { + #expect(PostgreSQLVersionedStatements.renameConstraint( + qualifiedTable: "\"public\".\"t\"", from: "a", to: "b", capabilities: Self.v91 + ) == nil) + #expect(PostgreSQLVersionedStatements.renameConstraint( + qualifiedTable: "\"public\".\"t\"", from: "a", to: "b", capabilities: Self.v92 + ) == "ALTER TABLE \"public\".\"t\" RENAME CONSTRAINT \"a\" TO \"b\"") + #expect(PostgreSQLVersionedStatements.renameConstraint( + qualifiedTable: "\"public\".\"t\"", from: "", to: "b", capabilities: Self.v17 + ) == nil) + } + + @Test("The column reorder copy carries OVERRIDING SYSTEM VALUE only where identity columns exist") + func copyRows() { + let legacy = PostgreSQLVersionedStatements.copyRows( + into: "\"public\".\"t\"", from: "\"public\".\"t_old\"", columnList: "\"a\", \"b\"", capabilities: Self.v96 + ) + #expect(legacy == "INSERT INTO \"public\".\"t\" (\"a\", \"b\") SELECT \"a\", \"b\" FROM \"public\".\"t_old\"") + let modern = PostgreSQLVersionedStatements.copyRows( + into: "\"public\".\"t\"", from: "\"public\".\"t_old\"", columnList: "\"a\"", capabilities: Self.v10 + ) + #expect(modern == "INSERT INTO \"public\".\"t\" (\"a\") OVERRIDING SYSTEM VALUE SELECT \"a\" FROM \"public\".\"t_old\"") + } + + @Test("A generated column is refused before 12 with the version it needs") + func generatedColumnRefusal() { + let plain = PluginColumnDefinition(name: "a", dataType: "int", generationExpression: nil, generationKind: nil) + let generated = PluginColumnDefinition( + name: "b", dataType: "int", generationExpression: "a * 2", generationKind: .stored + ) + let blankExpression = PluginColumnDefinition( + name: "c", dataType: "int", generationExpression: "", generationKind: nil + ) + #expect(PostgreSQLVersionedStatements.refusal(for: .addColumn(plain), capabilities: Self.v91) == nil) + #expect(PostgreSQLVersionedStatements.refusal(for: .addColumn(blankExpression), capabilities: Self.v91) == nil) + let reason = PostgreSQLVersionedStatements.refusal(for: .addColumn(generated), capabilities: Self.v11) + #expect(reason?.contains("b") == true) + #expect(reason?.contains("PostgreSQL 12 or later") == true) + #expect(PostgreSQLVersionedStatements.refusal(for: .addColumn(generated), capabilities: Self.v12) == nil) + } + + @Test("BRIN is refused before 9.5; FULLTEXT and SPATIAL are refused on every version") + func indexRefusal() { + func index(_ type: String?) -> PluginIndexDefinition { + PluginIndexDefinition(name: "ix", columns: ["a"], indexType: type) + } + let brinOld = PostgreSQLVersionedStatements.refusal( + for: .addIndex(index("brin")), capabilities: PostgreSQLCapabilities(serverVersion: 90_426) + ) + #expect(brinOld?.contains("9.5 or later") == true) + #expect(PostgreSQLVersionedStatements.refusal( + for: .addIndex(index("BRIN")), capabilities: PostgreSQLCapabilities(serverVersion: 90_500) + ) == nil) + for type in ["FULLTEXT", "spatial"] { + let reason = PostgreSQLVersionedStatements.refusal(for: .addIndex(index(type)), capabilities: Self.v17) + #expect(reason?.contains(type.uppercased()) == true) + } + for type in [nil, "", "BTREE", "HASH", "GIN", "GIST"] { + #expect(PostgreSQLVersionedStatements.refusal(for: .addIndex(index(type)), capabilities: Self.v91) == nil) + } + } + + @Test("Renaming a check constraint is refused on 9.1 only") + func renameRefusal() { + let rename = PluginSchemaOperation.renameCheckConstraint(from: "a", to: "b") + #expect(PostgreSQLVersionedStatements.refusal(for: rename, capabilities: Self.v91)?.contains("9.2 or later") == true) + #expect(PostgreSQLVersionedStatements.refusal(for: rename, capabilities: Self.v92) == nil) + } + + @Test("A table definition is refused by its first refused column or index") + func createTableRefusal() { + let definition = PluginCreateTableDefinition( + tableName: "t", + columns: [PluginColumnDefinition(name: "a", dataType: "int", generationExpression: nil, generationKind: nil)], + indexes: [PluginIndexDefinition(name: "ix", columns: ["a"], indexType: "BRIN")] + ) + #expect(PostgreSQLVersionedStatements.refusal( + for: definition, capabilities: PostgreSQLCapabilities(serverVersion: 90_426) + )?.contains("BRIN") == true) + #expect(PostgreSQLVersionedStatements.refusal(for: definition, capabilities: Self.v96) == nil) + } + + @Test("The structure editor hides generated fields before 12, BRIN before 9.5 and MySQL index types always") + func unsupportedStructureOptions() { + #expect(PostgreSQLVersionedStatements.unsupportedStructureColumnFields(capabilities: Self.v11) + == [.generated, .generationExpression]) + #expect(PostgreSQLVersionedStatements.unsupportedStructureColumnFields(capabilities: Self.v12).isEmpty) + #expect(PostgreSQLVersionedStatements.unsupportedIndexTypes(capabilities: Self.v91) == ["BRIN", "FULLTEXT", "SPATIAL"]) + #expect(PostgreSQLVersionedStatements.unsupportedIndexTypes(capabilities: Self.v96) == ["FULLTEXT", "SPATIAL"]) + } + + @Test("BYPASSRLS is a role attribute only from 9.5") + func roleAttributes() { + let legacy = PostgreSQLVersionedStatements.roleAttributes(capabilities: Self.v91) + #expect(!legacy.contains(.bypassrls)) + #expect(legacy.contains(.replication)) + #expect(legacy.count == PostgreSQLRoleAttribute.allCases.count - 1) + #expect(PostgreSQLVersionedStatements.roleAttributes(capabilities: Self.v96) == Set(PostgreSQLRoleAttribute.allCases)) + } +} diff --git a/TableProTests/Plugins/PostgreSQLViewDefinitionTests.swift b/TableProTests/Plugins/PostgreSQLViewDefinitionTests.swift new file mode 100644 index 0000000000..5eba57e5b1 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLViewDefinitionTests.swift @@ -0,0 +1,192 @@ +// +// PostgreSQLViewDefinitionTests.swift +// TableProTests +// +// Tests for PostgreSQLViewDefinition (compiled via project.yml from PostgreSQLDriverPlugin). +// The catalog values below are what PostgreSQL 17.11 returns for the objects described. +// + +import Foundation +import Testing + +@Suite("PostgreSQL view definition") +struct PostgreSQLViewDefinitionTests { + private let body = " SELECT id,\n v\n FROM sales.orders\n WHERE (id > 0);" + + private func row( + kind: PostgreSQLViewDefinition.Kind, + options: [String] = [], + accessMethod: String? = nil, + tablespace: String? = nil + ) -> PostgreSQLViewDefinition.CatalogRow { + PostgreSQLViewDefinition.CatalogRow( + kind: kind, + query: body, + options: options, + accessMethod: accessMethod, + tablespace: tablespace + ) + } + + // MARK: - Views + + @Test("A plain view is a CREATE OR REPLACE VIEW ending in one semicolon") + func plainView() { + let sql = PostgreSQLViewDefinition.statement(name: "vw", schema: "sales", row: row(kind: .view)) + + #expect(sql == """ + CREATE OR REPLACE VIEW "sales"."vw" AS + SELECT id, + v + FROM sales.orders + WHERE (id > 0); + """) + #expect(!sql.hasSuffix(";;")) + } + + /// `CREATE OR REPLACE VIEW` replaces the options it does not name, so a definition that left + /// these out turned a `security_barrier` or `security_invoker` view into an unrestricted one and + /// dropped its check option. Measured on 17.11: reloptions went to NULL and a role with rights + /// on the view alone could then read the base table through it. + @Test("A view keeps its options and its check option") + func viewKeepsOptionsAndCheckOption() { + let sql = PostgreSQLViewDefinition.statement( + name: "vw", + schema: "sales", + row: row( + kind: .view, + options: ["security_barrier=true", "security_invoker=true", "check_option=cascaded"] + ) + ) + + #expect(sql.hasPrefix( + "CREATE OR REPLACE VIEW \"sales\".\"vw\" WITH (security_barrier='true', security_invoker='true') AS" + )) + #expect(sql.hasSuffix("\n WITH CASCADED CHECK OPTION;")) + #expect(!sql.contains("check_option")) + } + + @Test("A local check option is named as such") + func localCheckOption() { + let sql = PostgreSQLViewDefinition.statement( + name: "vw", schema: "sales", row: row(kind: .view, options: ["check_option=local"]) + ) + + #expect(sql.hasSuffix("\n WITH LOCAL CHECK OPTION;")) + #expect(!sql.contains("WITH (")) + } + + // MARK: - Materialized views + + @Test("A materialized view is a CREATE MATERIALIZED VIEW") + func plainMaterializedView() { + let sql = PostgreSQLViewDefinition.statement( + name: "mv", schema: "sales", row: row(kind: .materializedView, accessMethod: "heap") + ) + + #expect(sql.hasPrefix("CREATE MATERIALIZED VIEW \"sales\".\"mv\" AS\n")) + #expect(sql.hasSuffix(";")) + } + + /// `USING` does not exist before PostgreSQL 12 and `heap` is what a server creates without it, + /// so it is written only for a view that is actually stored another way. + @Test("Only a non-default access method is written") + func accessMethodOnlyWhenNotHeap() { + let sql = PostgreSQLViewDefinition.statement( + name: "mv", + schema: "sales", + row: row(kind: .materializedView, accessMethod: "columnar", tablespace: "fast") + ) + + #expect(sql.hasPrefix("CREATE MATERIALIZED VIEW \"sales\".\"mv\" USING \"columnar\" TABLESPACE \"fast\" AS\n")) + } + + @Test("A materialized view keeps its storage parameters") + func materializedViewKeepsStorageParameters() { + let sql = PostgreSQLViewDefinition.statement( + name: "mv", schema: "sales", row: row(kind: .materializedView, options: ["fillfactor=70"]) + ) + + #expect(sql.contains("WITH (fillfactor='70')")) + } + + /// Population is the view's data state rather than part of its definition. Writing `WITH NO + /// DATA` into it would make two otherwise identical views compare as different and have schema + /// sync drop and recreate one, losing its rows. + @Test("Population state is not part of the statement") + func populationIsNotInTheStatement() { + let sql = PostgreSQLViewDefinition.statement( + name: "mv", schema: "sales", row: row(kind: .materializedView) + ) + + #expect(!sql.contains("WITH NO DATA")) + #expect(!sql.contains("WITH DATA")) + } + + // MARK: - Catalog parsing + + @Test("relkind selects the kind, and anything else is not a view") + func relkindMapping() { + #expect(PostgreSQLViewDefinition.kind(forRelkind: "v") == .view) + #expect(PostgreSQLViewDefinition.kind(forRelkind: "m") == .materializedView) + for relkind in ["r", "p", "f", "i", "S", "t", ""] { + #expect(PostgreSQLViewDefinition.kind(forRelkind: relkind) == nil) + } + } + + /// `reloptions` is read as array text: `array_to_json` does not exist on PostgreSQL 9.1. The + /// array decoder honours quoting, so a value holding a comma or a quote stays one option. + @Test("Options are decoded from the array text, commas and quotes inside a value included") + func optionsDecodedFromArrayText() { + let options: (String?) -> [String]? = { text in + PostgreSQLViewDefinition.parse(row: ["v", "SELECT 1", text, nil, nil])?.options + } + #expect(options("{security_barrier=true}") == ["security_barrier=true"]) + #expect(options("{fillfactor=70,autovacuum_enabled=false}") == ["fillfactor=70", "autovacuum_enabled=false"]) + #expect(options(#"{"note=a, b","q=\"x\""}"#) == ["note=a, b", #"q="x""#]) + #expect(options(nil)?.isEmpty == true) + } + + @Test("A catalog row parses into the kind, query, options and storage") + func parseRow() { + let parsed = PostgreSQLViewDefinition.parse(row: [ + "m", body, "{fillfactor=70}", "heap", "fast" + ]) + + #expect(parsed?.kind == .materializedView) + #expect(parsed?.query == body) + #expect(parsed?.options == ["fillfactor=70"]) + #expect(parsed?.accessMethod == "heap") + #expect(parsed?.tablespace == "fast") + } + + @Test("A row for something that is not a view parses to nothing") + func parseRejectsOtherKinds() { + #expect(PostgreSQLViewDefinition.parse(row: ["r", body, nil, nil, nil]) == nil) + #expect(PostgreSQLViewDefinition.parse(row: [nil, nil, nil, nil, nil]) == nil) + #expect(PostgreSQLViewDefinition.parse(row: ["v", body]) == nil) + } + + /// The body is read with `search_path` narrowed to `pg_catalog`, so every name in it is qualified and the text + /// binds to the same tables wherever it is run. + @Test("The catalog query narrows the search path and addresses the view by schema and name") + func catalogQueryIsQualifiedAndScoped() { + let query = PostgreSQLViewDefinition.catalogQuery(name: "vw", schema: "sales") + + #expect(PostgreSQLViewDefinition.qualifiedReadPrefix == "SET LOCAL search_path = pg_catalog; ") + #expect(query.contains("pg_catalog.pg_get_viewdef(c.oid, true)")) + #expect(query.contains("n.nspname = 'sales'")) + #expect(query.contains("c.relname = 'vw'")) + #expect(query.contains("c.relkind IN ('v', 'm')")) + #expect(query.contains("c.reloptions::text")) + #expect(!query.contains("json")) + } + + @Test("A name that needs quoting is quoted, and a literal that needs escaping is escaped") + func catalogQueryQuotesIdentifiers() { + let query = PostgreSQLViewDefinition.catalogQuery(name: "it's", schema: "My \"Odd\" Schema") + + #expect(query.contains("c.relname = 'it''s'")) + #expect(query.contains("n.nspname = 'My \"Odd\" Schema'")) + } +} diff --git a/TableProTests/Plugins/RedshiftExternalObjectsTests.swift b/TableProTests/Plugins/RedshiftExternalObjectsTests.swift index 9af554c325..fe38f49028 100644 --- a/TableProTests/Plugins/RedshiftExternalObjectsTests.swift +++ b/TableProTests/Plugins/RedshiftExternalObjectsTests.swift @@ -16,16 +16,16 @@ struct RedshiftExternalSchemaQueriesTests { private var allQueries: [String] { [ RedshiftExternalSchemaQueries.listExternalSchemaNames, - RedshiftExternalSchemaQueries.listExternalTables(schemaLiteral: "etl", databaseLiteral: "dev"), + RedshiftExternalSchemaQueries.listExternalTables(schema: "etl", database: "dev"), RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: "etl", - tableLiteral: "customers", - databaseLiteral: "dev" + schema: "etl", + table: "customers", + database: "dev" ), RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: "etl", - tableLiteral: nil, - databaseLiteral: "dev" + schema: "etl", + table: nil, + database: "dev" ), ] } @@ -52,7 +52,7 @@ struct RedshiftExternalSchemaQueriesTests { @Test("table listing filters on the requested schema and orders by name") func tableListingFiltersOnSchema() { - let query = RedshiftExternalSchemaQueries.listExternalTables(schemaLiteral: "etl", databaseLiteral: "dev") + let query = RedshiftExternalSchemaQueries.listExternalTables(schema: "etl", database: "dev") #expect(query.contains("FROM svv_external_tables")) #expect(query.contains("WHERE schemaname = 'etl'")) #expect(query.contains("ORDER BY tablename")) @@ -61,20 +61,20 @@ struct RedshiftExternalSchemaQueriesTests { @Test("every external catalog read is scoped to the connected database") func externalReadsAreScopedToDatabase() { - let tables = RedshiftExternalSchemaQueries.listExternalTables(schemaLiteral: "etl", databaseLiteral: "dev") + let tables = RedshiftExternalSchemaQueries.listExternalTables(schema: "etl", database: "dev") #expect(tables.contains("redshift_database_name = 'dev'")) let single = RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: "etl", - tableLiteral: "customers", - databaseLiteral: "dev" + schema: "etl", + table: "customers", + database: "dev" ) #expect(single.contains("redshift_database_name = 'dev'")) let all = RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: "etl", - tableLiteral: nil, - databaseLiteral: "dev" + schema: "etl", + table: nil, + database: "dev" ) #expect(all.contains("redshift_database_name = 'dev'")) } @@ -82,9 +82,9 @@ struct RedshiftExternalSchemaQueriesTests { @Test("single-table column query filters on the table and orders by column number") func singleTableColumnQuery() { let query = RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: "etl", - tableLiteral: "customers", - databaseLiteral: "dev" + schema: "etl", + table: "customers", + database: "dev" ) #expect(query.contains("FROM svv_external_columns")) #expect(query.contains("WHERE schemaname = 'etl'")) @@ -97,20 +97,20 @@ struct RedshiftExternalSchemaQueriesTests { @Test("all-tables column query prefixes tablename and omits the table filter") func allTablesColumnQuery() { let query = RedshiftExternalSchemaQueries.listExternalColumns( - schemaLiteral: "etl", - tableLiteral: nil, - databaseLiteral: "dev" + schema: "etl", + table: nil, + database: "dev" ) #expect(query.contains("tablename,")) #expect(!query.contains("AND tablename =")) #expect(query.contains("ORDER BY tablename, columnnum")) } - @Test("escaped schema literals reach the generated SQL intact") - func escapedLiteralsAreInterpolated() { + @Test("A raw name with an apostrophe reaches the generated SQL doubled") + func rawNamesAreQuoted() { let query = RedshiftExternalSchemaQueries.listExternalTables( - schemaLiteral: "o''brien", - databaseLiteral: "d''ev" + schema: "o'brien", + database: "d'ev" ) #expect(query.contains("WHERE schemaname = 'o''brien'")) #expect(query.contains("redshift_database_name = 'd''ev'")) diff --git a/TableProTests/Plugins/SQLExportCommentPhaseTests.swift b/TableProTests/Plugins/SQLExportCommentPhaseTests.swift new file mode 100644 index 0000000000..5e13f098aa --- /dev/null +++ b/TableProTests/Plugins/SQLExportCommentPhaseTests.swift @@ -0,0 +1,229 @@ +// +// SQLExportCommentPhaseTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("SQL export comments") +struct SQLExportCommentPhaseTests { + private final class StubExportDataSource: PluginExportDataSource, @unchecked Sendable { + let databaseTypeId: String + let commentDDL: [String: [String]] + let failingTables: Set + + init( + databaseTypeId: String = "PostgreSQL", + commentDDL: [String: [String]] = [:], + failingTables: Set = [] + ) { + self.databaseTypeId = databaseTypeId + self.commentDDL = commentDDL + self.failingTables = failingTables + } + + func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + continuation.yield(.header(PluginStreamHeader(columns: ["id"], columnTypeNames: ["INTEGER"]))) + continuation.yield(.rows([[.text("1")]])) + continuation.finish() + } + } + + func fetchTableDDL(table: String, databaseName: String) async throws -> String { + "CREATE TABLE \(table) (id INTEGER)" + } + + func fetchObjectDDL(_ object: PluginExportTable) async throws -> String { + switch object.kind { + case .view: return "CREATE OR REPLACE VIEW \(object.name) AS SELECT 1" + case .materializedView: return "CREATE MATERIALIZED VIEW \(object.name) AS SELECT 1" + case .routine: return "CREATE FUNCTION \(object.name)() RETURNS integer AS $$ SELECT 1 $$ LANGUAGE sql" + default: return try await fetchTableDDL(table: object.name, databaseName: object.databaseName) + } + } + + func fetchCommentDDL(table: String, databaseName: String) async throws -> [String] { + if failingTables.contains(table) { + throw StubError.unreadable + } + return commentDDL[table] ?? [] + } + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func quoteIdentifier(_ identifier: String) -> String { + "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + func escapeStringLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { nil } + } + + private enum StubError: Error { + case unreadable + } + + private func table( + _ name: String, + kind: PluginExportObjectKind = .table, + optionValues: [Bool] = [true, true, true] + ) -> PluginExportTable { + PluginExportTable( + name: name, + databaseName: "", + tableType: kind.rawValue, + optionValues: optionValues, + schema: nil, + kind: kind) + } + + private func runExport( + tables: [PluginExportTable], + dataSource: StubExportDataSource + ) async throws -> (dump: String, result: ExportFormatResult) { + let output = try await SQLExportHarness.shared.dump(tables: tables, dataSource: dataSource) + return (output.text, output.result) + } + + private func offset(of needle: String, in dump: String) throws -> Int { + let range = try #require(dump.range(of: needle), "\(needle) missing from the dump") + return dump.distance(from: dump.startIndex, to: range.lowerBound) + } + + @Test("A driver that reports comment DDL gets it into the dump, terminated") + func commentsReachTheDump() async throws { + let source = StubExportDataSource(commentDDL: ["orders": [ + "COMMENT ON TABLE orders IS 'Orders table'", + "COMMENT ON COLUMN orders.id IS 'Primary key'" + ]]) + + let (dump, result) = try await runExport(tables: [table("orders")], dataSource: source) + + #expect(dump.contains("COMMENT ON TABLE orders IS 'Orders table';")) + #expect(dump.contains("COMMENT ON COLUMN orders.id IS 'Primary key';")) + #expect(result.warnings.isEmpty) + } + + @Test("A comment rides with its own object: after the CREATE, before the rows and the indexes") + func commentsFollowTheCreate() async throws { + let source = StubExportDataSource( + commentDDL: ["orders": ["COMMENT ON TABLE orders IS 'Orders table'"]]) + + let (dump, _) = try await runExport(tables: [table("orders")], dataSource: source) + + #expect(try offset(of: "CREATE TABLE orders", in: dump) < offset(of: "COMMENT ON TABLE", in: dump)) + #expect(try offset(of: "COMMENT ON TABLE", in: dump) < offset(of: "INSERT INTO", in: dump)) + } + + @Test("A view's comments sit in its own block, right after its CREATE") + func viewCommentsFollowTheViewCreate() async throws { + let source = StubExportDataSource(commentDDL: ["v_orders": [ + "COMMENT ON VIEW v_orders IS 'View comment'", + "COMMENT ON COLUMN v_orders.id IS 'View column comment'" + ]]) + + let (dump, _) = try await runExport( + tables: [table("v_orders", kind: .view)], dataSource: source) + + #expect(try offset(of: "CREATE OR REPLACE VIEW", in: dump) < offset(of: "COMMENT ON VIEW", in: dump)) + #expect(dump.contains("COMMENT ON COLUMN v_orders.id IS 'View column comment';")) + } + + @Test("A materialized view's comment falls after its CREATE and before the next object header") + func matviewCommentFollowsItsCreate() async throws { + let source = StubExportDataSource( + commentDDL: ["m_orders": ["COMMENT ON MATERIALIZED VIEW m_orders IS 'Matview comment'"]]) + + let (dump, _) = try await runExport( + tables: [ + table("m_orders", kind: .materializedView), + table("r_sum", kind: .routine) + ], + dataSource: source) + + #expect(try offset(of: "CREATE MATERIALIZED VIEW", in: dump) + < offset(of: "COMMENT ON MATERIALIZED VIEW", in: dump)) + #expect(try offset(of: "COMMENT ON MATERIALIZED VIEW", in: dump) + < offset(of: "-- Routine: r_sum", in: dump)) + } + + @Test("A statement the driver already terminated is not terminated twice") + func terminatedStatementsStaySingleTerminated() async throws { + let source = StubExportDataSource( + commentDDL: ["orders": ["COMMENT ON TABLE orders IS 'Orders table';"]]) + + let (dump, _) = try await runExport(tables: [table("orders")], dataSource: source) + + #expect(dump.contains("IS 'Orders table';")) + #expect(!dump.contains("IS 'Orders table';;")) + } + + @Test("A source answering nothing writes no COMMENT ON anywhere") + func noCommentsWritesNothing() async throws { + let source = StubExportDataSource(databaseTypeId: "MySQL") + + let (dump, result) = try await runExport(tables: [table("orders")], dataSource: source) + + #expect(!dump.contains("COMMENT ON")) + #expect(result.warnings.isEmpty) + } + + @Test("A table with Structure unticked contributes no comment statements") + func structureGatesTheCommentPhase() async throws { + let source = StubExportDataSource( + commentDDL: ["orders": ["COMMENT ON TABLE orders IS 'Orders table'"]]) + + let (dump, _) = try await runExport( + tables: [table("orders", optionValues: [false, true, true])], dataSource: source) + + #expect(!dump.contains("COMMENT ON")) + } + + @Test("An unreadable comment list is named in the warnings and does not fail the export") + func unreadableCommentsAreReported() async throws { + let source = StubExportDataSource( + commentDDL: ["customers": ["COMMENT ON TABLE customers IS 'Customers'"]], + failingTables: ["orders"]) + + let (dump, result) = try await runExport( + tables: [table("orders"), table("customers")], dataSource: source) + + #expect(result.warnings.contains { $0.contains("Could not fetch comments for") && $0.contains("orders") }) + #expect(dump.contains("-- Warning: failed to fetch comments for orders")) + #expect(dump.contains("COMMENT ON TABLE customers IS 'Customers';")) + #expect(dump.contains("CREATE TABLE customers (id INTEGER);")) + #expect(dump.contains("INSERT INTO")) + } + + @Test("A routine is never asked for comments, so its definition is all that is written") + func routinesAreNotAskedForComments() async throws { + let source = StubExportDataSource( + commentDDL: ["r_sum": ["COMMENT ON FUNCTION r_sum() IS 'never asked'"]]) + + let (dump, _) = try await runExport( + tables: [table("r_sum", kind: .routine)], dataSource: source) + + #expect(!dump.contains("COMMENT ON FUNCTION")) + } + + @Test("A foreign table is never asked, because the dump writes CREATE TABLE for it") + func foreignTablesAreNotAskedForComments() async throws { + let source = StubExportDataSource( + commentDDL: ["f_orders": ["COMMENT ON FOREIGN TABLE f_orders IS 'Foreign comment'"]]) + + let (dump, result) = try await runExport( + tables: [table("f_orders", kind: .foreignTable)], dataSource: source) + + #expect(dump.contains("CREATE TABLE f_orders")) + #expect(!dump.contains("COMMENT ON FOREIGN TABLE")) + #expect(result.warnings.isEmpty) + } +} diff --git a/TableProTests/Plugins/SQLExportEncodingTests.swift b/TableProTests/Plugins/SQLExportEncodingTests.swift new file mode 100644 index 0000000000..e3096004d3 --- /dev/null +++ b/TableProTests/Plugins/SQLExportEncodingTests.swift @@ -0,0 +1,154 @@ +// +// SQLExportEncodingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("SQL export encoding declaration") +struct SQLExportEncodingTests { + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + @Test("A MySQL dump declares utf8mb4 the way mysqldump does, and puts the session back") + func mysqlDeclaresUTF8MB4() { + for typeId in ["MySQL", "MariaDB", "TiDB"] { + let declaration = SQLExportEncodingDeclaration.forDatabaseType(typeId) + #expect(declaration.prologue.contains("/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;")) + #expect(declaration.prologue.contains("/*!40101 SET NAMES utf8 */;")) + #expect(declaration.prologue.contains("/*!50503 SET NAMES utf8mb4 */;")) + #expect(declaration.epilogue.contains("/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;")) + #expect(declaration.epilogue.contains("/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;")) + #expect(declaration.epilogue.contains("/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;")) + } + } + + @Test("A PostgreSQL dump declares UTF8 the way pg_dump does, on every engine that accepts the statement") + func postgresDeclaresUTF8() { + for typeId in ["PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite"] { + let declaration = SQLExportEncodingDeclaration.forDatabaseType(typeId) + #expect(declaration.prologue == "SET client_encoding = 'UTF8';\n\n", "\(typeId)") + #expect(declaration.epilogue.isEmpty, "\(typeId)") + } + } + + @Test("Redshift gets no declaration, because it does not document client_encoding as settable") + func redshiftDeclaresNothing() { + #expect(SQLExportEncodingDeclaration.forDatabaseType("Redshift") == .empty) + } + + @Test("Other engines write no declaration") + func otherEnginesDeclareNothing() { + for typeId in ["SQLite", "DuckDB", "Oracle", "MSSQL", "SomeFuturePlugin"] { + #expect(SQLExportEncodingDeclaration.forDatabaseType(typeId) == .empty, "\(typeId)") + } + } + + @Test("A PostgreSQL dump opens with the declaration and ends with the last statement") + func postgresDumpIsPrefixed() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let declaration = SQLExportEncodingDeclaration.forDatabaseType("PostgreSQL") + + let destination = directory.appendingPathComponent("dump.sql") + let writer = try SQLExportFileWriter( + destination: destination, splitSizeMegabytes: 0, encodingDeclaration: declaration + ) + try writer.write("INSERT INTO \"t\" VALUES ('メール');\n") + try writer.commit() + + let dump = try String(contentsOf: destination, encoding: .utf8) + #expect(dump == "SET client_encoding = 'UTF8';\n\nINSERT INTO \"t\" VALUES ('メール');\n") + } + + @Test("An unsplit dump opens with the declaration and closes by restoring the session") + func unsplitDumpIsWrapped() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let declaration = SQLExportEncodingDeclaration.forDatabaseType("MySQL") + + let destination = directory.appendingPathComponent("dump.sql") + let writer = try SQLExportFileWriter( + destination: destination, splitSizeMegabytes: 0, encodingDeclaration: declaration + ) + try writer.write("INSERT INTO `t` VALUES ('メール');\n") + try writer.commit() + + let dump = try String(contentsOf: destination, encoding: .utf8) + #expect(dump == declaration.prologue + "INSERT INTO `t` VALUES ('メール');\n" + declaration.epilogue) + } + + @Test( + "Every part of a split dump carries its own declaration, so each restores on its own", + arguments: ["MySQL", "PostgreSQL"] + ) + func everyPartIsWrapped(databaseTypeId: String) throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let declaration = SQLExportEncodingDeclaration.forDatabaseType(databaseTypeId) + #expect(declaration != .empty) + + let destination = directory.appendingPathComponent("dump.sql") + let writer = try SQLExportFileWriter( + destination: destination, splitSizeMegabytes: 1, encodingDeclaration: declaration + ) + let chunk = String(repeating: "x", count: 700 * 1_024) + try writer.write("A\(chunk);\n") + try writer.write("B\(chunk);\n") + let parts = try writer.commit() + + #expect(parts.count == 2) + for (index, part) in parts.enumerated() { + let text = try String(contentsOf: part, encoding: .utf8) + #expect(text.hasPrefix(declaration.prologue), "part \(index + 1)") + #expect(text.hasSuffix(declaration.epilogue), "part \(index + 1)") + #expect(text.contains(index == 0 ? "A" : "B")) + } + } + + @Test("No part grows past the cap once its closing declaration is added") + func epilogueCountsTowardTheCap() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let declaration = SQLExportEncodingDeclaration.forDatabaseType("MySQL") + let cap = 1_024 * 1_024 + + let destination = directory.appendingPathComponent("dump.sql") + let writer = try SQLExportFileWriter( + destination: destination, splitSizeMegabytes: 1, encodingDeclaration: declaration + ) + let firstLength = cap - declaration.prologue.utf8.count - declaration.epilogue.utf8.count - 5 + try writer.write(String(repeating: "a", count: firstLength - 2) + ";\n") + try writer.write("SELECT 1;\n") + let parts = try writer.commit() + + #expect(parts.count == 2) + for part in parts { + let size = try FileManager.default.attributesOfItem(atPath: part.path)[.size] as? Int ?? 0 + #expect(size <= cap, "\(part.lastPathComponent) is \(size) bytes") + } + } + + @Test("A statement larger than the cap still lands in a part of its own, not after an empty one") + func oversizedStatementDoesNotLeaveAnEmptyPart() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let destination = directory.appendingPathComponent("dump.sql") + let writer = try SQLExportFileWriter( + destination: destination, splitSizeMegabytes: 1, + encodingDeclaration: .forDatabaseType("MySQL") + ) + try writer.write(String(repeating: "y", count: 2 * 1_024 * 1_024) + ";\n") + let parts = try writer.commit() + + #expect(parts.count == 1) + #expect(!writer.didSplit) + } +} diff --git a/TableProTests/Plugins/SQLStatementSplittingTests.swift b/TableProTests/Plugins/SQLStatementSplittingTests.swift new file mode 100644 index 0000000000..ca0a54ed5f --- /dev/null +++ b/TableProTests/Plugins/SQLStatementSplittingTests.swift @@ -0,0 +1,61 @@ +// +// SQLStatementSplittingTests.swift +// TableProTests +// + +import Foundation +import Testing +import TableProPluginKit + +@Suite("SQL statement splitting") +struct SQLStatementSplittingTests { + @Test("A batch splits on the semicolons that are separators") + func splitsOnSeparators() { + #expect(SQLStatementSplitting.statements(in: "SELECT 1; SELECT 2;") == ["SELECT 1", "SELECT 2"]) + #expect(SQLStatementSplitting.statements(in: "SELECT ';'") == ["SELECT ';'"]) + #expect(SQLStatementSplitting.statements(in: " ; ; ").isEmpty) + } + + @Test("A comment in front of a statement is dropped") + func leadingCommentsAreDropped() { + #expect(SQLStatementSplitting.statements(in: "-- note\nSELECT 1") == ["SELECT 1"]) + #expect(SQLStatementSplitting.statements(in: "/* note */ SELECT 1") == ["SELECT 1"]) + #expect(SQLStatementSplitting.statements(in: "# note\nSELECT 1") == ["SELECT 1"]) + #expect(SQLStatementSplitting.statements(in: "/* only a comment */").isEmpty) + } + + /// `/*!40101 ... */` is SQL, not a comment: MySQL runs the body on any server at or above the + /// version, MariaDB spells its own `/*M!100301 ... */`, and mysqldump writes its entire + /// preamble that way. Dropping them as comments left the MySQL driver blind to every `SET` a + /// restore ran, so it read the session as holding nothing. + @Test("A version-gated comment is kept, because the server executes it") + func executableCommentsAreKept() { + #expect( + SQLStatementSplitting.statements(in: "/*!40101 SET NAMES utf8 */;") + == ["/*!40101 SET NAMES utf8 */"] + ) + #expect( + SQLStatementSplitting.statements(in: "/*M!100301 SET @x = 1 */;") + == ["/*M!100301 SET @x = 1 */"] + ) + #expect( + SQLStatementSplitting.statements(in: "/*! SET @x = 1 */; SELECT 1") + == ["/*! SET @x = 1 */", "SELECT 1"] + ) + } + + /// Keeping it must not make it read as a transaction statement here. The keyword is no longer + /// the first word of the statement, and closing already requires the whole statement to be + /// one. `MySQLSessionFootprint` reads the body itself, because MySQL does run it; every other + /// caller treats the whole thing as a statement it does not recognise. + /// + /// `/*! */ COMMIT` is the case that tells the two implementations apart: stripping the + /// comment left a bare `COMMIT` and read as `.closes`. + @Test("A transaction keyword around a version-gated comment does not close a transaction") + func executableCommentsNeverClose() { + #expect(SQLTransactionTracking.effect(of: "BEGIN; /*! */ COMMIT") == .opens) + #expect(SQLTransactionTracking.effect(of: "/*! */ COMMIT") == .unchanged) + #expect(SQLTransactionTracking.effect(of: "BEGIN; /*!40101 COMMIT */") == .opens) + #expect(SQLTransactionTracking.effect(of: "/*!40101 COMMIT */") == .unchanged) + } +} diff --git a/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift b/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift new file mode 100644 index 0000000000..9aeebb21c6 --- /dev/null +++ b/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift @@ -0,0 +1,142 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProWeaviateCore +import Testing + +@Suite("Weaviate registry snapshot") +struct WeaviateRegistrySnapshotTests { + private func snapshot() throws -> PluginMetadataSnapshot { + let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() + return try #require(defaults.first { $0.typeId == "Weaviate" }).snapshot + } + + @Test("Weaviate is a collection engine on port 8080 with no SQL dialect") + func connectionShape() throws { + let snapshot = try snapshot() + #expect(snapshot.defaultPort == 8_080) + #expect(snapshot.editor.sqlDialect == nil) + #expect(snapshot.queryLanguageName == "GraphQL") + #expect(snapshot.schema.tableEntityName == "Collections") + #expect(snapshot.schema.defaultPrimaryKeyColumn == "uuid") + #expect(snapshot.schema.immutableColumns == ["uuid", "vector"]) + #expect(!snapshot.supportsForeignKeys) + #expect(!snapshot.capabilities.supportsSSH) + #expect(snapshot.capabilities.supportsSSL) + #expect(snapshot.connection.category == .document) + #expect(snapshot.iconName == "weaviate-icon") + } + + @Test("Auth field ids are Weaviate-prefixed and do not collide with Elasticsearch") + func authFieldIdsArePrefixed() throws { + let ids = try snapshot().connection.additionalConnectionFields.map(\.id) + #expect(ids == [ + WeaviateFieldID.authMethod, + WeaviateFieldID.apiKey, + WeaviateFieldID.skipTLSVerify + ]) + #expect(!ids.contains("esAuthMethod")) + #expect(!ids.contains("esApiKey")) + let elasticsearch = try #require( + PluginMetadataRegistry.shared.registryPluginDefaults().first { $0.typeId == "Elasticsearch" } + ) + let esIds = elasticsearch.snapshot.connection.additionalConnectionFields.map(\.id) + #expect(Set(ids).isDisjoint(with: Set(esIds))) + } +} + +@Suite("Weaviate connection fields") +struct WeaviateConnectionFieldsTests { + private func fields() throws -> [ConnectionField] { + let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() + let entry = try #require(defaults.first { $0.typeId == "Weaviate" }) + return entry.snapshot.connection.additionalConnectionFields + } + + @Test("Auth method defaults to none") + func authMethodDefaultsToNone() throws { + let fields = try fields() + let method = try #require(fields.first { $0.id == WeaviateFieldID.authMethod }) + #expect(method.defaultValue == "none") + guard case .dropdown(let options) = method.fieldType else { + Issue.record("Expected a dropdown field type") + return + } + #expect(options.map(\.value) == ["none", "apiKey"]) + } + + @Test("The API key replaces both built-in credential rows") + func apiKeyReplacesUsernameAndPassword() throws { + let fields = try fields() + let apiKey = try #require(fields.first { $0.id == WeaviateFieldID.apiKey }) + #expect(apiKey.isSecure) + #expect(!apiKey.isRequired) + #expect(apiKey.hidesPassword) + #expect(fields.hidesPassword(forValues: [:])) + #expect(fields.hidesUsername(forValues: [:])) + #expect(fields.hidesPassword(forValues: [WeaviateFieldID.authMethod: "none"])) + #expect(fields.hidesPassword(forValues: [WeaviateFieldID.authMethod: "apiKey"])) + #expect(fields.hidesUsername(forValues: [WeaviateFieldID.authMethod: "none"])) + #expect(fields.hidesUsername(forValues: [WeaviateFieldID.authMethod: "apiKey"])) + } + + @Test("The snapshot hides the built-in password for every auth method") + @MainActor + func snapshotHidesBuiltInPassword() throws { + let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() + let snapshot = try #require(defaults.first { $0.typeId == "Weaviate" }).snapshot + #expect(snapshot.connection.hidesBuiltInPassword) + for method in ["none", "apiKey"] { + var connection = DatabaseConnection(name: "Weaviate", type: .weaviate) + connection.additionalFields = [WeaviateFieldID.authMethod: method] + #expect(PluginManager.shared.hidesPassword(for: connection), "\(method)") + } + } +} + +@Suite("Weaviate field parity") +struct WeaviateFieldParityTests { + private static let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + private func source(_ path: String) throws -> String { + try String(contentsOf: Self.repoRoot.appendingPathComponent(path), encoding: .utf8) + } + + /// The plugin's own field list replaces the registry snapshot the moment the plugin is + /// installed, and no test can link both copies, so the two sources are compared as text. + @Test("The plugin's field list matches the registry copy") + func pluginCopyMatchesRegistryCopy() throws { + let plugin = try source("Plugins/WeaviateDriverPlugin/WeaviatePlugin.swift") + let registry = try source("TablePro/Core/Plugins/PluginMetadataRegistry+WeaviateDefaults.swift") + for field in [WeaviateFieldID.authMethod, WeaviateFieldID.apiKey, WeaviateFieldID.skipTLSVerify] { + #expect(registry.contains("id: \"\(field)\""), Comment(rawValue: field)) + } + #expect(plugin.contains("id: WeaviateFieldID.authMethod")) + #expect(plugin.contains("id: WeaviateFieldID.apiKey")) + #expect(plugin.contains("id: WeaviateFieldID.skipTLSVerify")) + for copy in [plugin, registry] { + #expect(copy.contains("hidesPassword: true")) + #expect(copy.contains("withHidesUsername(true)")) + #expect(copy.contains("apiKey")) + } + } +} + +@Suite("Weaviate plugin manifest") +struct WeaviatePluginManifestTests { + @Test("The bundle pins the PluginKit ABI and the Weaviate type id") + func plistDeclaresType() throws { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Plugins/WeaviateDriverPlugin/Info.plist") + let plist = try #require(NSDictionary(contentsOf: url) as? [String: Any]) + #expect(plist["TableProPluginKitVersion"] as? Int == PluginManager.currentPluginKitVersion) + #expect(plist["TableProProvidesDatabaseTypeIds"] as? [String] == ["Weaviate"]) + #expect(plist["TableProMinAppVersion"] as? String == "0.73.0") + } +} diff --git a/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift index 7d4fedbd6f..144260d66f 100644 --- a/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift +++ b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift @@ -76,4 +76,79 @@ struct ForeignKeyLabelColumnStoreTests { #expect(store.labelColumn(for: target) == "full name") #expect(store.labelColumn(for: scope(connectionId: target.connectionId)) == nil) } + + @Test("A table rename moves its choice and leaves a longer name alone") + func renameTableMovesOnlyThatTable() throws { + let store = try makeStore() + let connection = UUID() + let other = UUID() + store.setLabelColumn("Name", for: scope(connectionId: connection)) + store.setLabelColumn("Title", for: scope(connectionId: connection, table: "Artist_archive")) + store.setLabelColumn("Code", for: scope(connectionId: other)) + + store.renameTable( + from: scope(connectionId: connection), + to: scope(connectionId: connection, table: "Performer") + ) + + #expect(store.labelColumn(for: scope(connectionId: connection)) == nil) + #expect(store.labelColumn(for: scope(connectionId: connection, table: "Performer")) == "Name") + #expect(store.labelColumn(for: scope(connectionId: connection, table: "Artist_archive")) == "Title") + #expect(store.labelColumn(for: scope(connectionId: other)) == "Code") + } + + @Test("A schema rename moves every table in it and nothing outside it") + func renameContainerMovesTheSchema() throws { + let store = try makeStore() + let connection = UUID() + let other = UUID() + store.setLabelColumn("Name", for: scope(connectionId: connection, schema: "music")) + store.setLabelColumn("Title", for: scope(connectionId: connection, schema: "music", table: "Album")) + store.setLabelColumn("Code", for: scope(connectionId: connection, schema: "music_old")) + store.setLabelColumn("Email", for: scope(connectionId: other, schema: "music")) + + store.renameContainer( + connectionId: connection, fromDatabase: "chinook", fromSchema: "music", + toDatabase: "chinook", toSchema: "catalog" + ) + + #expect(store.labelColumn(for: scope(connectionId: connection, schema: "catalog")) == "Name") + #expect(store.labelColumn(for: scope(connectionId: connection, schema: "catalog", table: "Album")) == "Title") + #expect(store.labelColumn(for: scope(connectionId: connection, schema: "music")) == nil) + #expect(store.labelColumn(for: scope(connectionId: connection, schema: "music_old")) == "Code") + #expect(store.labelColumn(for: scope(connectionId: other, schema: "music")) == "Email") + } + + @Test("A database rename moves its tables and leaves a longer database name alone") + func renameDatabaseMovesItsTables() throws { + let store = try makeStore() + let connection = UUID() + store.setLabelColumn("Name", for: scope(connectionId: connection)) + store.setLabelColumn("Title", for: scope(connectionId: connection, database: "chinook_backup")) + + store.renameContainer( + connectionId: connection, fromDatabase: "chinook", fromSchema: nil, + toDatabase: "music", toSchema: nil + ) + + #expect(store.labelColumn(for: scope(connectionId: connection, database: "music")) == "Name") + #expect(store.labelColumn(for: scope(connectionId: connection)) == nil) + #expect(store.labelColumn(for: scope(connectionId: connection, database: "chinook_backup")) == "Title") + } + + @Test("Deleting a connection removes its choices and keeps every other connection's") + func purgeConnectionsRemovesOnlyThatConnection() throws { + let store = try makeStore() + let connection = UUID() + let other = UUID() + store.setLabelColumn("Name", for: scope(connectionId: connection)) + store.setLabelColumn("Title", for: scope(connectionId: connection, table: "Album")) + store.setLabelColumn("Code", for: scope(connectionId: other)) + + store.purgeConnections([connection]) + + #expect(store.labelColumn(for: scope(connectionId: connection)) == nil) + #expect(store.labelColumn(for: scope(connectionId: connection, table: "Album")) == nil) + #expect(store.labelColumn(for: scope(connectionId: other)) == "Code") + } } diff --git a/TableProTests/Theme/BundledThemeStatementHighlightTests.swift b/TableProTests/Theme/BundledThemeStatementHighlightTests.swift deleted file mode 100644 index 7ccb562a05..0000000000 --- a/TableProTests/Theme/BundledThemeStatementHighlightTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// BundledThemeStatementHighlightTests.swift -// TableProTests -// -// `EditorThemeColors` falls back to the light defaults for any key a theme omits, so a dark theme that forgets -// `currentStatementHighlight` paints the light value over dark text. Nothing at runtime notices, which is why this -// reads the shipped JSON rather than the decoded theme. -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("Bundled themes declare a statement highlight") -struct BundledThemeStatementHighlightTests { - - private static let themeIds = [ - "tablepro.default-light", - "tablepro.default-dark", - "tablepro.dracula", - "tablepro.nord", - ] - - private static var themesDirectory: URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("TablePro/Resources/Themes", isDirectory: true) - } - - private func editorColors(for id: String) throws -> [String: Any] { - let url = Self.themesDirectory.appendingPathComponent("\(id).json") - let data = try Data(contentsOf: url) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let editor = json?["editor"] as? [String: Any] - return try #require(editor) - } - - @Test("Every bundled theme declares currentStatementHighlight", arguments: themeIds) - func themeDeclaresStatementHighlight(id: String) throws { - let colors = try editorColors(for: id) - let value = colors["currentStatementHighlight"] as? String - #expect(value?.isEmpty == false, "\(id) omits currentStatementHighlight") - } - - /// A band the same colour as the caret line or the selection tells the reader nothing. Dracula and Nord already - /// set those two equal to each other, so the collision is live rather than hypothetical. - @Test("The statement highlight differs from the line highlight and the selection", arguments: themeIds) - func statementHighlightIsDistinct(id: String) throws { - let colors = try editorColors(for: id) - let statement = colors["currentStatementHighlight"] as? String - - #expect(statement != colors["currentLineHighlight"] as? String, "\(id): band matches the caret line") - #expect(statement != colors["selection"] as? String, "\(id): band matches the selection") - #expect(statement != colors["background"] as? String, "\(id): band matches the background") - } - - /// The band is painted under the caret line highlight, so it has to layer with it rather than cover it. - @Test("The statement highlight carries alpha", arguments: themeIds) - func statementHighlightCarriesAlpha(id: String) throws { - let colors = try editorColors(for: id) - let value = try #require(colors["currentStatementHighlight"] as? String) - - #expect(value.count == 9, "\(id): expected #RRGGBBAA, got \(value)") - } -} diff --git a/TableProTests/Theme/ThemeDefinitionTests.swift b/TableProTests/Theme/ThemeDefinitionTests.swift deleted file mode 100644 index d6c60cea2f..0000000000 --- a/TableProTests/Theme/ThemeDefinitionTests.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// ThemeDefinitionTests.swift -// TableProTests -// -// Tests for ThemeDefinition and EditorThemeColors, focusing on the -// currentStatementHighlight field and Codable backward compatibility. -// - -import AppKit -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -@Suite("Theme Definition") -struct ThemeDefinitionTests { - // MARK: - Default light theme - - /// The band is painted under the caret line highlight, so it carries alpha rather than being opaque. An opaque - /// value covers the caret line instead of layering with it. - @Test("Default light editor colors include currentStatementHighlight") - func defaultLightHasCurrentStatementHighlight() { - let colors = EditorThemeColors.defaultLight - #expect(colors.currentStatementHighlight == "#0A0A0A0F") - } - - @Test("Default light editor colors have expected background") - func defaultLightBackground() { - let colors = EditorThemeColors.defaultLight - #expect(colors.background == "#FFFFFF") - } - - // MARK: - Codable round-trip - - @Test("EditorThemeColors survives encode-decode round-trip") - func editorThemeColorsRoundTrip() throws { - let original = EditorThemeColors.defaultLight - let encoder = JSONEncoder() - let data = try encoder.encode(original) - let decoder = JSONDecoder() - let decoded = try decoder.decode(EditorThemeColors.self, from: data) - - #expect(decoded.currentStatementHighlight == original.currentStatementHighlight) - #expect(decoded.background == original.background) - #expect(decoded.text == original.text) - #expect(decoded.cursor == original.cursor) - #expect(decoded.currentLineHighlight == original.currentLineHighlight) - #expect(decoded.selection == original.selection) - #expect(decoded.lineNumber == original.lineNumber) - #expect(decoded.invisibles == original.invisibles) - #expect(decoded == original) - } - - @Test("Full ThemeDefinition survives encode-decode round-trip") - func themeDefinitionRoundTrip() throws { - let original = ThemeDefinition.default - let encoder = JSONEncoder() - let data = try encoder.encode(original) - let decoder = JSONDecoder() - let decoded = try decoder.decode(ThemeDefinition.self, from: data) - - #expect(decoded.editor.currentStatementHighlight == original.editor.currentStatementHighlight) - #expect(decoded == original) - } - - // MARK: - Backward compatibility - - @Test("Decoding JSON missing currentStatementHighlight falls back to default") - func backwardCompatibilityMissingField() throws { - // JSON with all editor fields EXCEPT currentStatementHighlight - let json = """ - { - "background": "#1E1E1E", - "text": "#D4D4D4", - "cursor": "#AEAFAD", - "currentLineHighlight": "#2A2D2E", - "selection": "#264F78", - "lineNumber": "#858585", - "invisibles": "#3B3B3B", - "syntax": { - "keyword": "#569CD6", - "string": "#CE9178", - "number": "#B5CEA8", - "comment": "#6A9955", - "null": "#569CD6", - "operator": "#D4D4D4", - "function": "#DCDCAA", - "type": "#4EC9B0" - } - } - """ - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) - - // Should fall back to defaultLight's value - #expect(decoded.currentStatementHighlight == EditorThemeColors.defaultLight.currentStatementHighlight) - // Other fields should use the provided values - #expect(decoded.background == "#1E1E1E") - #expect(decoded.text == "#D4D4D4") - } - - @Test("Decoding empty JSON falls back to all defaults") - func emptyJsonFallsBackToDefaults() throws { - let json = "{}" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) - - #expect(decoded == EditorThemeColors.defaultLight) - } - - @Test("Decoding JSON with currentStatementHighlight preserves custom value") - func customCurrentStatementHighlight() throws { - let json = """ - { - "background": "#FFFFFF", - "text": "#000000", - "cursor": "#000000", - "currentLineHighlight": "#ECF5FF", - "selection": "#B4D8FD", - "lineNumber": "#747478", - "invisibles": "#D6D6D6", - "currentStatementHighlight": "#AABBCC", - "syntax": { - "keyword": "#9B2393", - "string": "#C41A16", - "number": "#1C00CF", - "comment": "#5D6C79", - "null": "#9B2393", - "operator": "#000000", - "function": "#326D74", - "type": "#3F6E74" - } - } - """ - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) - - #expect(decoded.currentStatementHighlight == "#AABBCC") - } - - // MARK: - Editor font resolver - - @Test("Font resolver always exposes System Mono") - func resolverExposesSystemMono() { - let families = EditorFontResolver.availableMonospacedFamilies - #expect(families.contains { $0.id == EditorFontResolver.systemMonoId }) - } - - @Test("System Mono is first in picker list") - func systemMonoFirst() { - let families = EditorFontResolver.availableMonospacedFamilies - #expect(families.first?.id == EditorFontResolver.systemMonoId) - } - - @Test("Editor font cache falls back for unknown font family") - func editorCacheFallsBackForUnknownFamily() { - let fonts = ThemeFonts( - editorFontFamily: "NoSuchFamily-XYZ", - editorFontSize: 13, - dataGridFontFamily: "System Mono", - dataGridFontSize: 13 - ) - let cache = EditorFontCache(from: fonts) - #expect(cache.font.pointSize > 0) - } - - @Test("Data grid cache falls back for unknown font family") - func dataGridCacheFallsBackForUnknownFamily() { - let fonts = ThemeFonts( - editorFontFamily: "System Mono", - editorFontSize: 13, - dataGridFontFamily: "NoSuchFamily-XYZ", - dataGridFontSize: 13 - ) - let cache = DataGridFontCacheResolved(from: fonts) - #expect(cache.regular.pointSize > 0) - #expect(cache.monoCharWidth > 0) - } - - @Test("Resolver list has unique IDs") - func resolverListHasUniqueIds() { - let ids = EditorFontResolver.availableMonospacedFamilies.map(\.id) - #expect(Set(ids).count == ids.count) - } - - @Test("Unknown family reports unavailable") - func unknownFamilyUnavailable() { - #expect(EditorFontResolver.isAvailable(familyId: "NoSuchFamily-XYZ") == false) - } - - @Test("ThemeFonts decode keeps legacy family strings") - func themeFontsDecodeKeepsLegacyStrings() throws { - let json = #"{"editorFontFamily":"Menlo","editorFontSize":13,"dataGridFontFamily":"Monaco","dataGridFontSize":13}"# - let decoded = try JSONDecoder().decode(ThemeFonts.self, from: Data(json.utf8)) - #expect(decoded.editorFontFamily == "Menlo") - #expect(decoded.dataGridFontFamily == "Monaco") - } - - @Test("All resolver font families are monospaced") - func allResolverFamiliesAreMonospaced() { - let families = EditorFontResolver.availableMonospacedFamilies - for family in families where family.id != EditorFontResolver.systemMonoId { - let font = EditorFontResolver.resolve(familyId: family.id, size: 12) - #expect(font.fontDescriptor.symbolicTraits.contains(.monoSpace)) - } - } -} diff --git a/TableProTests/Theme/ThemeDocumentTests.swift b/TableProTests/Theme/ThemeDocumentTests.swift new file mode 100644 index 0000000000..8346849fd8 --- /dev/null +++ b/TableProTests/Theme/ThemeDocumentTests.swift @@ -0,0 +1,181 @@ +// +// ThemeDocumentTests.swift +// TableProTests +// +// The previous decoder read every key with `decodeIfPresent` and a Default Light fallback, so an +// empty object and a VS Code theme both decoded "successfully" and rendered as Default Light under +// the file's own name. These pin the gate that replaced it. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Theme document gate") +struct ThemeDocumentTests { + private static func json(_ object: [String: Any]) -> Data { + // swiftlint:disable:next force_try + try! JSONSerialization.data(withJSONObject: object) + } + + private static func completeTheme(appearance: ThemeAppearance = .dark) -> [String: Any] { + var root: [String: Any] = [ + "schema": ThemeSchema.current, + "id": "user.test", + "name": "Test", + "author": "Tests", + "appearance": appearance.rawValue, + ] + + for slot in ThemeSlot.allCases { + var cursor = root + insert(slot.rawValue.components(separatedBy: "."), value: "#112233", into: &cursor) + root = cursor + } + + return root + } + + private static func insert(_ path: [String], value: String, into container: inout [String: Any]) { + guard let head = path.first else { return } + guard path.count > 1 else { + container[head] = value + return + } + var child = container[head] as? [String: Any] ?? [:] + insert(Array(path.dropFirst()), value: value, into: &child) + container[head] = child + } + + @Test("A complete document decodes") + func completeDocumentDecodes() throws { + let document = try ThemeDocument(data: Self.json(Self.completeTheme())) + #expect(document.id == "user.test") + #expect(document.appearance == .dark) + #expect(document.colors.count == ThemeSlot.allCases.count) + } + + @Test("An empty object is rejected") + func emptyObjectIsRejected() { + #expect(throws: ThemeLoadError.missingSchema) { + try ThemeDocument(data: Self.json([:])) + } + } + + @Test("A file in the old format is rejected") + func oldSchemaIsRejected() { + let old: [String: Any] = [ + "version": 1, + "id": "user.old", + "name": "Old", + "appearance": "dark", + "editor": ["background": "#000000"], + ] + + #expect(throws: ThemeLoadError.missingSchema) { + try ThemeDocument(data: Self.json(old)) + } + } + + @Test("A schema from a newer TablePro is rejected") + func newerSchemaIsRejected() { + var root = Self.completeTheme() + root["schema"] = ThemeSchema.current + 1 + + #expect(throws: ThemeLoadError.schemaTooNew(found: ThemeSchema.current + 1, supported: ThemeSchema.current)) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("A missing slot is reported, not filled in silently") + func missingSlotIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor.removeValue(forKey: "cursor") + content["editor"] = editor + root["content"] = content + + #expect(throws: ThemeLoadError.missingSlots([ThemeSlot.editorCursor.rawValue])) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("A key TablePro does not use is reported") + func unknownKeyIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + content["sidebar"] = ["background": "#000000"] + root["content"] = content + + #expect(throws: ThemeLoadError.unknownKeys(["content.sidebar.background"])) { + try ThemeDocument(data: Self.json(root)) + } + } + + /// A valid prefix used to satisfy the parser, so `#FF79CG` rendered as `#0FF79C` with nothing + /// logged and nothing shown. + @Test("A color with a typo in it is rejected") + func malformedColorIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor["background"] = "#FF79CG" + content["editor"] = editor + root["content"] = content + + #expect(throws: ThemeLoadError.invalidColor("#FF79CG")) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("An unknown system color name is rejected") + func unknownSystemColorIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor["background"] = "system:notARealColor" + content["editor"] = editor + root["content"] = content + + #expect(throws: ThemeLoadError.unknownSystemColor("notARealColor")) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("A system color reference round-trips") + func systemColorRoundTrips() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor["background"] = "system:textBackground" + content["editor"] = editor + root["content"] = content + + let theme = try ThemeDocument(data: Self.json(root)).resolved() + #expect(theme.editor.background == .system(.textBackground)) + + let reread = try ThemeDocument(data: ThemeEncoder.data(for: theme)).resolved() + #expect(reread.editor.background == .system(.textBackground)) + } + + @Test("Every slot round-trips through the encoder") + func everySlotRoundTrips() throws { + let theme = try ThemeDocument(data: Self.json(Self.completeTheme())).resolved() + let reread = try ThemeDocument(data: ThemeEncoder.data(for: theme)).resolved() + + for slot in ThemeSlot.allCases { + #expect(reread[keyPath: slot.keyPath] == theme[keyPath: slot.keyPath], "\(slot.rawValue)") + } + } + + @Test("Hex parsing consumes the whole string") + func hexParsingIsStrict() { + #expect(HexColor.canonicalize("#ff79c6") == "#FF79C6") + #expect(HexColor.canonicalize("#FF79C680") == "#FF79C680") + #expect(HexColor.canonicalize("#FF79CG") == nil) + #expect(HexColor.canonicalize("FF79C6") == nil) + #expect(HexColor.canonicalize("#FFF") == nil) + #expect(HexColor.canonicalize("") == nil) + } +} diff --git a/TableProTests/Theme/ThemeSlotCoverageTests.swift b/TableProTests/Theme/ThemeSlotCoverageTests.swift new file mode 100644 index 0000000000..bcbf96e21a --- /dev/null +++ b/TableProTests/Theme/ThemeSlotCoverageTests.swift @@ -0,0 +1,115 @@ +// +// ThemeSlotCoverageTests.swift +// TableProTests +// +// Whole colour groups shipped declared, editable and read by nothing: `sidebar` and `toolbar` had +// no reader at all, `ui` had one, and `ui.accentColor` was declared by every bundled theme and had +// no property to decode into. A slot exists here only because a call site reads it. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Theme slot coverage") +struct ThemeSlotCoverageTests { + private static let repositoryRoot: URL = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 3 { + url.deleteLastPathComponent() + } + return url + }() + + /// The files that declare the slots. Everything else counts as a reader, including + /// `ThemeEngine.makeEditorTheme`, which is where the editor and syntax slots are read. + private static let definitionFiles: Set = [ + "ThemeDefinition.swift", "BuiltInThemes.swift", "ThemeColorValue.swift", + "ThemeDocument.swift", "ThemePalette.swift", "ThemeCatalog.swift", "ThemeResolver.swift", + ] + + private static let appSource: String = { + let directory = repositoryRoot.appendingPathComponent("TablePro", isDirectory: true) + guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { + return "" + } + + var source = "" + for case let url as URL in enumerator where url.pathExtension == "swift" { + guard !Self.definitionFiles.contains(url.lastPathComponent) else { continue } + source += (try? String(contentsOf: url, encoding: .utf8)) ?? "" + } + return source + }() + + @Test("Every slot is read by app code") + func everySlotHasAReader() { + #expect(!Self.appSource.isEmpty) + + for slot in ThemeSlot.allCases { + #expect(Self.appSource.contains(".\(String(describing: slot))"), "Nothing reads \(slot.rawValue)") + } + } + + @Test("Slot paths are unique and namespaced") + func slotPathsAreWellFormed() { + let paths = ThemeSlot.allCases.map(\.rawValue) + #expect(Set(paths).count == paths.count) + + for path in paths { + #expect(path.hasPrefix("content."), "\(path)") + #expect(path.components(separatedBy: ".").count >= 3, "\(path)") + } + } + + @Test("Every slot belongs to exactly one group") + func groupsPartitionTheSlots() { + let grouped = ThemeSlotGroup.allCases.flatMap(\.slots) + #expect(Set(grouped) == Set(ThemeSlot.allCases)) + #expect(grouped.count == ThemeSlot.allCases.count) + } + + // MARK: - Bundled themes + + @Test("Every bundled theme passes the gate", arguments: ["tablepro.dracula", "tablepro.nord"]) + func bundledThemesPassTheGate(id: String) throws { + let url = Self.repositoryRoot.appendingPathComponent("TablePro/Resources/Themes/\(id).json") + let document = try ThemeDocument(data: try Data(contentsOf: url)) + + #expect(document.id == id) + #expect(document.colors.count == ThemeSlot.allCases.count) + } + + /// The bundled themes are the only ones shipped, so a hand-typed colour that never renders is + /// caught here rather than by a user. + @Test("Bundled themes declare a real colour for every slot", arguments: ["tablepro.dracula", "tablepro.nord"]) + func bundledThemesDeclareHexColors(id: String) throws { + let url = Self.repositoryRoot.appendingPathComponent("TablePro/Resources/Themes/\(id).json") + let theme = try ThemeDocument(data: try Data(contentsOf: url)).resolved() + + for slot in ThemeSlot.allCases { + let value = theme[keyPath: slot.keyPath] + #expect(!value.isSystem, "\(id) leaves \(slot.rawValue) on a system colour") + } + } + + /// Default Light and Default Dark keep the surrounds on system colours so the unthemed app is + /// unchanged and keeps the system's Increase Contrast handling. + @Test("The default themes keep the grid surrounds on system colours") + func defaultThemesUseSystemSurrounds() { + for theme in BuiltInThemes.all { + #expect(theme.dataGrid.background.isSystem) + #expect(theme.dataGrid.text.isSystem) + #expect(theme.dataGrid.alternateRow.isSystem) + #expect(theme.dataGrid.selection.isSystem) + #expect(theme.editor.background.isSystem == false) + } + } + + @Test("The default themes declare the appearance of their slot") + func defaultThemesDeclareTheirAppearance() { + #expect(BuiltInThemes.light.appearance == .light) + #expect(BuiltInThemes.dark.appearance == .dark) + #expect(BuiltInThemes.default(for: .dark).id == BuiltInThemes.defaultDarkId) + } +} diff --git a/TableProTests/Theme/ThemeSlotValidationTests.swift b/TableProTests/Theme/ThemeSlotValidationTests.swift index fa5b0167c7..633071e4eb 100644 --- a/TableProTests/Theme/ThemeSlotValidationTests.swift +++ b/TableProTests/Theme/ThemeSlotValidationTests.swift @@ -20,27 +20,20 @@ struct ThemeSlotValidationTests { #expect(ThemeSlotValidation.fits(.light, slot: .dark) == false) } - @Test("An auto theme fits both slots") - func autoFitsBoth() { - #expect(ThemeSlotValidation.fits(.auto, slot: .light)) - #expect(ThemeSlotValidation.fits(.auto, slot: .dark)) - } - private func theme(_ id: String, _ appearance: ThemeAppearance) -> ThemeDefinition { - var copy = ThemeDefinition.default + var copy = BuiltInThemes.default(for: appearance) copy.id = id - copy.appearance = appearance return copy } private var sample: [ThemeDefinition] { - [theme("light", .light), theme("dark", .dark), theme("auto", .auto)] + [theme("light", .light), theme("dark", .dark)] } @Test("Only fitting themes stay in the list") func listIsFiltered() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: nil) - #expect(eligible.map(\.id) == ["light", "auto"]) + #expect(eligible.map(\.id) == ["light"]) } /// The row the user is standing on can never be filtered away, because the alternative was to @@ -48,18 +41,56 @@ struct ThemeSlotValidationTests { @Test("A contradicting theme stays listed while it is the one selected") func selectedContradictingThemeIsKept() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "dark") - #expect(eligible.map(\.id) == ["light", "dark", "auto"]) + #expect(eligible.map(\.id) == ["light", "dark"]) } @Test("Keeping a selection does not duplicate a theme that already fits") func keptSelectionIsNotDuplicated() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "light") - #expect(eligible.map(\.id) == ["light", "auto"]) + #expect(eligible.map(\.id) == ["light"]) } @Test("An unknown selected id adds nothing to the list") func unknownSelectionAddsNothing() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .dark, keeping: "does.not.exist") - #expect(eligible.map(\.id) == ["dark", "auto"]) + #expect(eligible.map(\.id) == ["dark"]) + } + + /// A slot whose theme is missing, rejected, or of the wrong appearance falls back to that + /// slot's own built-in. Falling back to Default Light whichever slot asked painted a white + /// editor inside dark chrome. + @Test("A dark slot that cannot resolve falls back to Default Dark") + func darkSlotFallsBackToDefaultDark() { + let selection = ThemeResolver.resolve( + mode: .dark, + lightThemeId: BuiltInThemes.defaultLightId, + darkThemeId: "user.deleted", + themes: BuiltInThemes.all, + systemIsDark: true + ) + + #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) + #expect(selection.active.appearance == .dark) + } + + @Test("A light theme sitting in the dark slot falls back to Default Dark") + func misfitThemeInDarkSlotFallsBack() { + let selection = ThemeResolver.resolve( + mode: .dark, + lightThemeId: BuiltInThemes.defaultLightId, + darkThemeId: BuiltInThemes.defaultLightId, + themes: BuiltInThemes.all, + systemIsDark: true + ) + + #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) + } + + @Test("Auto follows the system appearance") + func autoFollowsSystem() { + #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: true) == .dark) + #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: false) == .light) + #expect(ThemeResolver.effectiveAppearance(mode: .light, systemIsDark: true) == .light) + #expect(ThemeResolver.effectiveAppearance(mode: .dark, systemIsDark: false) == .dark) } } diff --git a/TableProTests/Theme/ValueFontTests.swift b/TableProTests/Theme/ValueFontTests.swift index 0e80b9b7df..ba040ee5f5 100644 --- a/TableProTests/Theme/ValueFontTests.swift +++ b/TableProTests/Theme/ValueFontTests.swift @@ -15,10 +15,10 @@ import Foundation @testable import TablePro import Testing -/// The first two tests activate a theme on the shared `ThemeEngine`. What keeps that from reaching a +/// The first two tests change the fonts on the shared `ThemeEngine`. What keeps that from reaching a /// suite running in parallel is that both bodies are synchronous and `@MainActor`, so nothing else on -/// the main actor can interleave between activating the test theme and restoring the original one, the -/// same way `DataGridRowTintThemeTests` holds. Adding an `await` inside `withTheme` would break it. +/// the main actor can interleave between applying the test fonts and restoring the original ones, the +/// same way `DataGridRowTintThemeTests` holds. Adding an `await` inside `withTypography` breaks it. @Suite("Stored value font", .serialized) @MainActor struct ValueFontTests { @@ -30,30 +30,27 @@ struct ValueFontTests { return url }() - private static func theme(editorSize: Int, gridSize: Int) -> ThemeDefinition { - var theme = ThemeDefinition.default - theme.id = "user.value-font-tests" - theme.fonts = ThemeFonts( + private static func typography(editorSize: Int, gridSize: Int) -> TypographySettings { + TypographySettings( editorFontFamily: "Menlo", editorFontSize: editorSize, dataGridFontFamily: "Courier", dataGridFontSize: gridSize ) - return theme } - private func withTheme(_ theme: ThemeDefinition, _ body: () -> Void) { - let previous = ThemeEngine.shared.activeTheme - ThemeEngine.shared.activateTheme(theme) + private func withTypography(_ typography: TypographySettings, _ body: () -> Void) { + let previous = AppSettingsManager.shared.typography + ThemeEngine.shared.apply(typography: typography) body() - ThemeEngine.shared.activateTheme(previous) + ThemeEngine.shared.apply(typography: previous) } // MARK: - Which setting the value font comes from @Test("The value font is the data grid font, not the editor font") func valueFontFollowsTheDataGridFont() { - withTheme(Self.theme(editorSize: 18, gridSize: 11)) { + withTypography(Self.typography(editorSize: 18, gridSize: 11)) { let engine = ThemeEngine.shared #expect(engine.valueFont == engine.dataGridFonts.regular) #expect(engine.valueFont != engine.editorFonts.font) @@ -67,8 +64,8 @@ struct ValueFontTests { func valueFontIgnoresTheEditorSize() { var afterSmall: NSFont? var afterLarge: NSFont? - withTheme(Self.theme(editorSize: 11, gridSize: 13)) { afterSmall = ThemeEngine.shared.valueFont } - withTheme(Self.theme(editorSize: 18, gridSize: 13)) { afterLarge = ThemeEngine.shared.valueFont } + withTypography(Self.typography(editorSize: 11, gridSize: 13)) { afterSmall = ThemeEngine.shared.valueFont } + withTypography(Self.typography(editorSize: 18, gridSize: 13)) { afterLarge = ThemeEngine.shared.valueFont } #expect(afterSmall == afterLarge) } diff --git a/TableProTests/Views/Backup/BackupResultSheetSkippedSettingsTests.swift b/TableProTests/Views/Backup/BackupResultSheetSkippedSettingsTests.swift new file mode 100644 index 0000000000..d41f05e274 --- /dev/null +++ b/TableProTests/Views/Backup/BackupResultSheetSkippedSettingsTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("BackupResultSheet skipped settings note") +struct BackupResultSheetSkippedSettingsTests { + @Test("No note when nothing was skipped") + func noSettings() { + #expect(BackupResultSheet.skippedSettingsNote([]) == nil) + } + + @Test("One setting is named on its own") + func oneSetting() { + let note = BackupResultSheet.skippedSettingsNote(["transaction_timeout"]) + #expect(note?.contains("transaction_timeout") == true) + } + + @Test("Every setting is named, however many there are") + func severalSettings() { + let settings = [ + "lock_timeout", "idle_in_transaction_session_timeout", "transaction_timeout", + "row_security", "default_table_access_method" + ] + let note = BackupResultSheet.skippedSettingsNote(settings) + for setting in settings { + #expect(note?.contains(setting) == true) + } + } +} diff --git a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift index dd2e4f15c5..37d0c2e8e3 100644 --- a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift +++ b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift @@ -193,7 +193,7 @@ struct QueryDiagnosticMessageTests { let emphases = manager.getEmphases(for: QueryDiagnosticsController.emphasisGroup) #expect(emphases.map(\.range) == [range]) - #expect(emphases.first?.style == .underline(color: .systemOrange)) + #expect(emphases.first?.style == .underline(color: ThemeEngine.shared.palette[.statusWarning])) #expect( manager.toolTip(at: try center(of: range, in: controller)) == "Full-width semicolon (U+FF1B). SQL reads only ; as a statement separator." diff --git a/TableProTests/Views/Editor/SyntaxHighlightingTests.swift b/TableProTests/Views/Editor/SyntaxHighlightingTests.swift index 784b5575fe..c91f5a3c30 100644 --- a/TableProTests/Views/Editor/SyntaxHighlightingTests.swift +++ b/TableProTests/Views/Editor/SyntaxHighlightingTests.swift @@ -177,11 +177,11 @@ struct SyntaxHighlightingTests { @MainActor @Test("The theme's operator and function colours reach the editor") func themeCarriesOperatorAndFunctionColors() { - let colors = ThemeEngine.shared.colors.editor + let resolved = ThemeEngine.shared.resolved let theme = ThemeEngine.shared.makeEditorTheme() - #expect(Self.sameColor(theme.operators.color, colors.operator)) - #expect(Self.sameColor(theme.functions.color, colors.function)) + #expect(Self.sameColor(theme.operators.color, resolved[.syntaxOperator])) + #expect(Self.sameColor(theme.functions.color, resolved[.syntaxFunction])) } // MARK: - Helpers diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift index 976309a591..212b0e01b2 100644 --- a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -268,7 +268,7 @@ struct CommandActionsBulkCloseTests { tab.content.sourceFileURL = URL(fileURLWithPath: "/tmp/tablepro-mixed.sql") tab.content.savedFileContent = "SELECT 1" tab.content.query = "SELECT 2" - tab.pendingChanges.deletedRowIndices = [0] + tab.pendingChanges.deletedRowIDs = [.existing(0)] } let mixed = try #require(current.coordinator.tabManager.tabs.first) diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift index fce1b2dc3c..2ada5371aa 100644 --- a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -52,7 +52,7 @@ struct CoordinatorColumnVisibilityTests { triggerReload: false ) coordinator.changeManager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 521bdf0149..ed824077a7 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -91,7 +91,7 @@ struct EvictionTests { let (coordinator, tabManager) = makeCoordinator() try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - tabManager.tabs[0].pendingChanges.deletedRowIndices = [0] + tabManager.tabs[0].pendingChanges.deletedRowIDs = [.existing(0)] let loadEpoch = tabManager.tabs[0].loadEpoch try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") diff --git a/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift b/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift index 1a09dab853..ec1a499a4c 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift @@ -88,7 +88,7 @@ struct MainContentCoordinatorDisplayStateTests { seedRows(coordinator, for: tab.id) let before = coordinator.displayState(for: tabManager.tabs[0]) - coordinator.tabSessionRegistry.updateTableRows(for: tab.id) { _ in } + coordinator.tabSessionRegistry.updateTableRows(for: tab.id) { _ in .none } #expect(coordinator.displayState(for: tabManager.tabs[0]) === before) } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index e84e86ab66..3aef26c4f0 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -131,7 +131,7 @@ struct MainContentCoordinatorLazyLoadTests { Issue.record("expected tab to exist") return } - tabManager.tabs[idx].pendingChanges.deletedRowIndices = [0] + tabManager.tabs[idx].pendingChanges.deletedRowIDs = [.existing(0)] coordinator.lazyLoadCurrentTabIfNeeded() #expect(coordinator.pendingLoadTrigger == nil) diff --git a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift index 96e4d4d4f5..b1616e6282 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift @@ -350,7 +350,7 @@ struct MainContentCoordinatorTabSwitchTests { triggerReload: false ) coordinator.changeManager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -388,7 +388,7 @@ struct MainContentCoordinatorTabSwitchTests { triggerReload: false ) coordinator.changeManager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", @@ -572,7 +572,7 @@ struct MainContentCoordinatorTabSwitchTests { triggerReload: false ) coordinator.changeManager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "total", oldValue: "10", @@ -895,7 +895,7 @@ struct MainContentCoordinatorTabSwitchTests { triggerReload: false ) manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: "Alice", diff --git a/TableProTests/Views/Main/MaintenanceSheetIdentityTests.swift b/TableProTests/Views/Main/MaintenanceSheetIdentityTests.swift index 88f9b92b46..34d55a09b4 100644 --- a/TableProTests/Views/Main/MaintenanceSheetIdentityTests.swift +++ b/TableProTests/Views/Main/MaintenanceSheetIdentityTests.swift @@ -9,17 +9,28 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing @Suite("Maintenance sheet identity") struct MaintenanceSheetIdentityTests { + private func operation(_ name: String) -> PluginMaintenanceOperation { + PluginMaintenanceOperation(name: name, appliesTo: [.table], scope: .object) + } + @Test("The same table in two databases is two different requests") func distinguishesTwoDatabases() { let online = ActiveSheet.maintenance( - operation: "OPTIMIZE TABLE", tableName: "role_ability", database: "banshi_online", schema: nil + operation: operation("OPTIMIZE TABLE"), + tableName: "role_ability", + database: "banshi_online", + schema: nil ) let test = ActiveSheet.maintenance( - operation: "OPTIMIZE TABLE", tableName: "role_ability", database: "banshi_test", schema: nil + operation: operation("OPTIMIZE TABLE"), + tableName: "role_ability", + database: "banshi_test", + schema: nil ) #expect(online.id != test.id) @@ -28,10 +39,10 @@ struct MaintenanceSheetIdentityTests { @Test("The same table in two schemas of one database is two different requests") func distinguishesTwoSchemas() { let publicSchema = ActiveSheet.maintenance( - operation: "VACUUM", tableName: "orders", database: "app", schema: "public" + operation: operation("VACUUM"), tableName: "orders", database: "app", schema: "public" ) let reporting = ActiveSheet.maintenance( - operation: "VACUUM", tableName: "orders", database: "app", schema: "reporting" + operation: operation("VACUUM"), tableName: "orders", database: "app", schema: "reporting" ) #expect(publicSchema.id != reporting.id) @@ -40,10 +51,10 @@ struct MaintenanceSheetIdentityTests { @Test("The same object is the same request") func matchesTheSameObject() { let first = ActiveSheet.maintenance( - operation: "ANALYZE TABLE", tableName: "orders", database: "app", schema: "public" + operation: operation("ANALYZE TABLE"), tableName: "orders", database: "app", schema: "public" ) let second = ActiveSheet.maintenance( - operation: "ANALYZE TABLE", tableName: "orders", database: "app", schema: "public" + operation: operation("ANALYZE TABLE"), tableName: "orders", database: "app", schema: "public" ) #expect(first.id == second.id) @@ -52,12 +63,37 @@ struct MaintenanceSheetIdentityTests { @Test("A request that names no database is not the same as one that does") func distinguishesAnUnnamedDatabase() { let named = ActiveSheet.maintenance( - operation: "OPTIMIZE TABLE", tableName: "orders", database: "app", schema: nil + operation: operation("OPTIMIZE TABLE"), tableName: "orders", database: "app", schema: nil ) let unnamed = ActiveSheet.maintenance( - operation: "OPTIMIZE TABLE", tableName: "orders", database: nil, schema: nil + operation: operation("OPTIMIZE TABLE"), tableName: "orders", database: nil, schema: nil ) #expect(named.id != unnamed.id) } + + /// The identity keys on the operation's name, so two requests that differ only in the options the + /// descriptor declares are the same sheet. The sheet seeds its own state from those options. + @Test("Two descriptors with the same name are the same request") + func ignoresDescriptorShape() { + let plain = ActiveSheet.maintenance( + operation: PluginMaintenanceOperation(name: "VACUUM", appliesTo: [.table], scope: .object), + tableName: "orders", + database: "app", + schema: "public" + ) + let withOptions = ActiveSheet.maintenance( + operation: PluginMaintenanceOperation( + name: "VACUUM", + appliesTo: [.table, .materializedView], + scope: .objectOrDatabase, + options: [PluginMaintenanceOption(key: "full", label: "FULL", defaultValue: "false")] + ), + tableName: "orders", + database: "app", + schema: "public" + ) + + #expect(plain.id == withOptions.id) + } } diff --git a/TableProTests/Views/Main/QueryFailureReportingTests.swift b/TableProTests/Views/Main/QueryFailureReportingTests.swift index 8b824f33ba..5dfcf26ab9 100644 --- a/TableProTests/Views/Main/QueryFailureReportingTests.swift +++ b/TableProTests/Views/Main/QueryFailureReportingTests.swift @@ -202,7 +202,7 @@ struct QueryFailureReportingTests { tabManager.selectedTabId = selectedTabId coordinator.changeManager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 1, columnName: "name", oldValue: nil, diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index 577126b481..eb163d7508 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -51,6 +51,14 @@ struct ResultStatusBarLayoutTests { onReset: {}, onJumpToColumn: nil ), + highlightState: StatusBarHighlightState( + rules: [], + columns: hasColumns ? ["id", "name"] : [], + isPersisted: tabType == .table, + presentationRequest: 0, + onChange: { _ in }, + onDismiss: {} + ), paginationCallbacks: PaginationCallbacks( onFirst: {}, onPrevious: {}, diff --git a/TableProTests/Views/Main/TabCloseProtectionTests.swift b/TableProTests/Views/Main/TabCloseProtectionTests.swift index ad72a7f3f9..50f99b13b0 100644 --- a/TableProTests/Views/Main/TabCloseProtectionTests.swift +++ b/TableProTests/Views/Main/TabCloseProtectionTests.swift @@ -33,7 +33,7 @@ struct TabCloseProtectionTests { databaseType: .mysql, generatedColumns: [] ) - coordinator.changeManager.recordRowDeletion(rowIndex: 0, originalRow: Self.originalRow) + coordinator.changeManager.recordRowDeletion(rowID: .existing(0), originalRow: Self.originalRow) } // MARK: - The reported bug diff --git a/TableProTests/Views/Main/ValueFilterEditedRowTests.swift b/TableProTests/Views/Main/ValueFilterEditedRowTests.swift new file mode 100644 index 0000000000..72be60fac4 --- /dev/null +++ b/TableProTests/Views/Main/ValueFilterEditedRowTests.swift @@ -0,0 +1,219 @@ +// +// ValueFilterEditedRowTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class EditedRowLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@Suite("Value filter after an edit takes a row out of its match") +@MainActor +struct ValueFilterEditedRowTests { + private struct Fixture { + let coordinator: MainContentCoordinator + let tabId: UUID + } + + private func makeFixture() -> Fixture { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table, tableName: "users") + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [ + [.text("1"), .text("Alice")], + [.text("2"), .text("Bob")], + [.text("3"), .text("Carol")] + ], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)], + hasAuthoritativeSchema: true + ), + for: tab.id + ) + coordinator.changeManager.configureForTable( + tableName: "users", + columns: ["id", "name"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + coordinator.setValueFilter(nameFilter(["Bob", "Carol"]), forTab: tab.id) + return Fixture(coordinator: coordinator, tabId: tab.id) + } + + private func nameFilter(_ names: Set) -> GridValueFilterState { + var state = GridValueFilterState() + state.set( + ColumnValueFilter(selectedValues: names, includesNull: false), + columnName: "name", + forColumn: 1 + ) + return state + } + + private func mountGrid(on fixture: Fixture) -> TableViewCoordinator { + let coordinator = fixture.coordinator + let tabId = fixture.tabId + let delegate = DataTabGridDelegate() + let grid = TableViewCoordinator( + changeManager: AnyChangeManager(coordinator.changeManager), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: EditedRowLayoutPersister() + ) + delegate.dataGridAttach(tableViewCoordinator: grid) + coordinator.dataTabDelegate = delegate + grid.tableRowsProvider = { [weak coordinator] in + coordinator?.tabSessionRegistry.existingTableRows(for: tabId) ?? TableRows() + } + grid.tableRowsMutator = { [weak coordinator] mutate in + coordinator?.mutateActiveTableRows(for: tabId) { rows in mutate(&rows) } ?? .none + } + grid.valueFilterBinding = Binding( + get: { coordinator.tabManager.tabs.first { $0.id == tabId }?.valueFilter ?? GridValueFilterState() }, + set: { coordinator.setValueFilter($0, forTab: tabId) } + ) + grid.displayOrderProvider = { [weak coordinator] in + coordinator?.displayIDs(forTab: tabId) + } + grid.adoptValueFilter(coordinator.tabManager.tabs.first { $0.id == tabId }?.valueFilter ?? GridValueFilterState()) + grid.recomputeValueFilteredIDs() + grid.updateCache() + return grid + } + + private func renameBobInStorage(_ fixture: Fixture, to name: String) { + fixture.coordinator.mutateActiveTableRows(for: fixture.tabId) { rows in + rows.edit(row: 1, column: 1, value: .text(name)) + } + } + + private func stringParameters(_ statement: ParameterizedStatement) -> [String] { + statement.parameters.compactMap { $0 as? String } + } + + @Test("an edit that takes a row out of the filter leaves it at its display position") + func editKeepsTheRowInTheDisplayOrder() { + let fixture = makeFixture() + #expect(fixture.coordinator.activeGridDisplayIDs == [.existing(1), .existing(2)]) + + renameBobInStorage(fixture, to: "Zed") + + #expect(fixture.coordinator.activeGridDisplayIDs == [.existing(1), .existing(2)]) + } + + @Test("the order is resolved when the filter is set, not when a reader first asks for it") + func orderDoesNotDependOnWhenItIsFirstRead() { + let fixture = makeFixture() + renameBobInStorage(fixture, to: "Zed") + + #expect(fixture.coordinator.activeGridDisplayIDs == [.existing(1), .existing(2)]) + } + + @Test("the mounted grid and the owner agree on the display order after an inline edit") + func gridAndOwnerAgreeAfterAnInlineEdit() { + let fixture = makeFixture() + let grid = mountGrid(on: fixture) + + grid.recordCellEdit(row: 0, columnIndex: 1, newValue: .text("Zed")) + grid.recomputeValueFilteredIDs() + + #expect(grid.displayIDs == [.existing(1), .existing(2)]) + #expect(grid.displayIDs == fixture.coordinator.activeGridDisplayIDs) + #expect(grid.displayRow(at: 0)?.values[1] == .text("Zed")) + } + + @Test("a grid with an owner shows the owner's order rather than resolving its own") + func gridTakesTheOwnersOrder() { + let fixture = makeFixture() + let grid = mountGrid(on: fixture) + grid.displayOrderProvider = { [.existing(2)] } + + grid.recomputeValueFilteredIDs() + + #expect(grid.displayIDs == [.existing(2)]) + } + + @Test("deleting the row the grid shows after an edit deletes that row and no other") + func deleteAfterAnEditTargetsTheShownRow() throws { + let fixture = makeFixture() + let grid = mountGrid(on: fixture) + grid.recordCellEdit(row: 0, columnIndex: 1, newValue: .text("Zed")) + + fixture.coordinator.deleteSelectedRows(indices: [0]) + + let deletes = try fixture.coordinator.changeManager.generateSQL() + .filter { $0.sql.hasPrefix("DELETE") } + #expect(deletes.count == 1) + let parameters = deletes.first.map(stringParameters) ?? [] + #expect(parameters.contains("2")) + #expect(!parameters.contains("3")) + } + + @Test("a row inspector save after an edit updates the row the grid shows") + func sidebarSaveAfterAnEditTargetsTheShownRow() throws { + let fixture = makeFixture() + renameBobInStorage(fixture, to: "Zed") + fixture.coordinator.selectionState.indices = [0] + + let statements = try fixture.coordinator.sidebarEditStatements( + editedFields: [(columnIndex: 1, columnName: "name", newValue: "Yan")] + ) + + #expect(statements.count == 1) + let parameters = statements.first.map(stringParameters) ?? [] + #expect(parameters.contains("Yan")) + #expect(parameters.contains("2")) + #expect(!parameters.contains("3")) + } + + @Test("adding a row re-resolves the order, so the edited row leaves with the next row-set change") + func addingARowReResolvesTheOrder() { + let fixture = makeFixture() + renameBobInStorage(fixture, to: "Zed") + + fixture.coordinator.mutateActiveTableRows(for: fixture.tabId) { rows in + rows.appendInsertedRow(values: [.text("4"), .text("Dan")]) + } + + guard let insertedID = fixture.coordinator.tabSessionRegistry.tableRows(for: fixture.tabId).rows.last?.id else { + Issue.record("no inserted row") + return + } + #expect(insertedID.isInserted) + #expect(fixture.coordinator.activeGridDisplayIDs == [RowID.existing(2), insertedID]) + } + + @Test("changing the filter re-resolves the order over the edited values") + func changingTheFilterReResolvesTheOrder() { + let fixture = makeFixture() + renameBobInStorage(fixture, to: "Zed") + + fixture.coordinator.setValueFilter(nameFilter(["Zed"]), forTab: fixture.tabId) + + #expect(fixture.coordinator.activeGridDisplayIDs == [.existing(1)]) + } +} diff --git a/TableProTests/Views/Results/DataGridBodyChromeTests.swift b/TableProTests/Views/Results/DataGridBodyChromeTests.swift index 7a3fff2e94..2d657dd715 100644 --- a/TableProTests/Views/Results/DataGridBodyChromeTests.swift +++ b/TableProTests/Views/Results/DataGridBodyChromeTests.swift @@ -168,14 +168,12 @@ struct DataGridBodyChromeTests { #expect(!grid.coordinator.presentsColumn(atTableColumnIndex: rowNumber)) } - /// The colour has to come from `tableView.gridColor`, which is the dynamic catalog colour AppKit - /// was filling with, so an appearance change carries the separator with it and there is no - /// second spelling to keep in sync. A hardcoded colour would pass a geometry test and be wrong - /// in dark mode. - @Test("The separator is drawn in the table view's own grid colour") - func separatorUsesTheTableViewGridColor() throws { + /// The colour has to come from the theme's grid line slot, which is what the rest of the grid + /// paints with. `tableView.gridColor` is not consulted: AppKit's grid lines are off, and a + /// hardcoded grey would pass a geometry test and be wrong in dark mode. + @Test("The separator is drawn in the theme's grid line colour") + func separatorUsesTheThemeGridLineColor() throws { let grid = makeGrid(columns: ["id", "name"]) - grid.tableView.gridColor = .systemRed let rowView = try #require(grid.tableView.rowView(atRow: 0, makeIfNecessary: true) as? DataGridRowView) rowView.layoutSubtreeIfNeeded() @@ -189,13 +187,14 @@ struct DataGridBodyChromeTests { x: Int((boundary - 0.5) * scale), y: Int(rowView.bounds.height * scale / 2) )?.usingColorSpace(.deviceRGB) - let expected = NSColor.systemRed.usingColorSpace(.deviceRGB) + let expected = ThemeEngine.shared.palette[.gridLine].usingColorSpace(.deviceRGB) let sampledRed = try #require(sampled?.redComponent) let sampledGreen = try #require(sampled?.greenComponent) let expectedRed = try #require(expected?.redComponent) + let expectedGreen = try #require(expected?.greenComponent) #expect(abs(sampledRed - expectedRed) < 0.15) - #expect(sampledRed > sampledGreen + 0.3, "the separator has to carry the grid colour, not a fixed grey") + #expect(abs(sampledGreen - expectedGreen) < 0.15) } /// `NSTableView` continues the alternation past the last row one row height at a time, numbered diff --git a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift index 731f8fbc37..aa622c28fe 100644 --- a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift +++ b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift @@ -19,6 +19,25 @@ struct DataGridCellAppearanceTests { regularFont: .systemFont(ofSize: 13), italicFont: .systemFont(ofSize: 13), mediumFont: .systemFont(ofSize: 13, weight: .medium), + text: .labelColor, + placeholderText: .secondaryLabelColor, + booleanTrueText: .labelColor, + booleanFalseText: .labelColor, + rowNumberText: .secondaryLabelColor, + deletedRowText: .systemRed, + modifiedColumnTint: .systemYellow, + findMatchTint: .systemOrange + ) + + private let themedPalette = DataGridCellPalette( + regularFont: .systemFont(ofSize: 13), + italicFont: .systemFont(ofSize: 13), + mediumFont: .systemFont(ofSize: 13, weight: .medium), + text: .systemPurple, + placeholderText: .systemBrown, + booleanTrueText: .systemGreen, + booleanFalseText: .systemPink, + rowNumberText: .systemTeal, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, findMatchTint: .systemOrange @@ -36,7 +55,8 @@ struct DataGridCellAppearanceTests { isCurrentFindMatch: Bool = false, columnIndex: Int = 0, onEmphasizedSelection: Bool = false, - hasOverlay: Bool = false + hasOverlay: Bool = false, + palette: DataGridCellPalette? = nil ) -> DataGridCellAppearance { DataGridCellAppearance.resolve( kind: kind, @@ -50,7 +70,7 @@ struct DataGridCellAppearanceTests { row: 0, columnIndex: columnIndex ), - palette: palette, + palette: palette ?? self.palette, nullDisplayString: "NULL", onEmphasizedSelection: onEmphasizedSelection, hasOverlay: hasOverlay @@ -179,4 +199,126 @@ struct DataGridCellAppearanceTests { func noAccessoryNoRole() { #expect(resolve().accessoryRole == nil) } + + // MARK: - Highlight rules + + private func highlighted( + column: Int, + color: HighlightColor = .green, + isDeleted: Bool = false, + isInserted: Bool = false, + modifiedColumns: Set = [] + ) -> RowVisualState { + let rule = HighlightRule(columnName: "status", value: "paid", color: color, target: .cell) + return RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: modifiedColumns, + highlight: RowHighlight(rowRule: nil, cellRules: [column: rule]) + ) + } + + @Test("A cell a highlight rule matches takes the rule's wash") + func highlightedCellTakesTheWash() { + let appearance = resolve(visualState: highlighted(column: 1), columnIndex: 1) + + #expect(appearance.backgroundTint == HighlightColor.green.washColor) + #expect(resolve(visualState: highlighted(column: 1), columnIndex: 2).backgroundTint == nil) + } + + @Test("A modified cell keeps the modified tint over a highlight") + func modifiedTintOutranksHighlight() { + let appearance = resolve(visualState: highlighted(column: 0, modifiedColumns: [0]), columnIndex: 0) + + #expect(appearance.backgroundTint == palette.modifiedColumnTint) + } + + @Test("A find match and a selection both outrank a highlight") + func findAndSelectionOutrankHighlight() { + let found = resolve(visualState: highlighted(column: 0), isCurrentFindMatch: true, columnIndex: 0) + let selected = resolve(visualState: highlighted(column: 0), columnIndex: 0, onEmphasizedSelection: true) + + #expect(found.backgroundTint == palette.findMatchTint) + #expect(selected.backgroundTint == nil) + } + + @Test("A pending insert or delete shows no cell highlight, so it cannot pass for one") + func pendingRowsShowNoCellHighlight() { + let inserted = resolve(visualState: highlighted(column: 0, isInserted: true), columnIndex: 0) + let deleted = resolve(visualState: highlighted(column: 0, isDeleted: true), columnIndex: 0) + + #expect(inserted.backgroundTint == nil) + #expect(deleted.backgroundTint == nil) + } + + @Test("Only a highlight that is drawn is named to VoiceOver") + func drawnHighlightRule() { + let rowRule = HighlightRule(columnName: "status", value: "paid", color: .green) + let cellRule = HighlightRule(columnName: "total", value: "9", color: .red, target: .cell) + let highlight = RowHighlight(rowRule: rowRule, cellRules: [1: cellRule]) + let plain = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [], highlight: highlight) + let modified = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [1], highlight: highlight) + let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: [], highlight: highlight) + + #expect(plain.drawnHighlightRule(forColumn: 1) == cellRule) + #expect(plain.drawnHighlightRule(forColumn: 0) == rowRule) + #expect(modified.drawnHighlightRule(forColumn: 1) == rowRule) + #expect(deleted.drawnHighlightRule(forColumn: 1) == nil) + } + + // MARK: - Theme colors + + @Test("A value takes the theme's text color, a placeholder its NULL color") + func themeTextAndPlaceholderColors() { + let value = resolve(palette: themedPalette) + let null = resolve(text: "", rawValue: nil, placeholder: .null, palette: themedPalette) + let empty = resolve(text: "", rawValue: "", placeholder: .empty, palette: themedPalette) + + #expect(value.textColor == .systemPurple) + #expect(null.textColor == .systemBrown) + #expect(empty.textColor == .systemBrown) + } + + @Test("A boolean cell takes the theme's true or false color", arguments: [ + ("true", NSColor.systemGreen), ("1", .systemGreen), ("t", .systemGreen), + ("false", .systemPink), ("0", .systemPink), ("f", .systemPink), + ]) + func booleanColors(raw: String, expected: NSColor) { + let appearance = resolve(kind: .boolean, text: raw, rawValue: raw, palette: themedPalette) + + #expect(appearance.textColor == expected) + } + + @Test("A boolean color stays off text columns and unreadable values") + func booleanColorNeedsABooleanValue() { + let textColumn = resolve(kind: .text, text: "true", rawValue: "true", palette: themedPalette) + let unreadable = resolve(kind: .boolean, text: "maybe", rawValue: "maybe", palette: themedPalette) + + #expect(textColumn.textColor == .systemPurple) + #expect(unreadable.textColor == .systemPurple) + } + + @Test("A theme without boolean colors draws booleans as plain text") + func booleanFallsBackToText() { + let appearance = resolve(kind: .boolean, text: "true", rawValue: "true") + + #expect(appearance.textColor == .labelColor) + } + + @Test("Selection, deletion and a find match outrank the boolean color") + func booleanColorYieldsToState() { + let selected = resolve(kind: .boolean, text: "1", rawValue: "1", onEmphasizedSelection: true, palette: themedPalette) + let deleted = resolve( + kind: .boolean, + text: "1", + rawValue: "1", + visualState: RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []), + palette: themedPalette + ) + let found = resolve(kind: .boolean, text: "1", rawValue: "1", isCurrentFindMatch: true, palette: themedPalette) + + #expect(selected.textColor == .alternateSelectedControlTextColor) + #expect(deleted.textColor == .systemRed) + #expect(found.textColor == .black) + } } diff --git a/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift index 1647eedafa..8a6c4a6f84 100644 --- a/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift +++ b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift @@ -409,7 +409,7 @@ struct ChangeReapplyVersionTests { let initialVersion = manager.reloadVersion manager.recordCellChange( - rowIndex: 0, + rowID: .existing(0), columnIndex: 0, columnName: "name", oldValue: "old", diff --git a/TableProTests/Views/Results/DataGridPendingChangeMarkTests.swift b/TableProTests/Views/Results/DataGridPendingChangeMarkTests.swift new file mode 100644 index 0000000000..51bc39df43 --- /dev/null +++ b/TableProTests/Views/Results/DataGridPendingChangeMarkTests.swift @@ -0,0 +1,241 @@ +// +// DataGridPendingChangeMarkTests.swift +// TableProTests +// +// A pending change used to show as a tint and nothing else, so selecting the rows about to be +// saved hid every mark at once and a client reading the grid was told nothing at all. These cover +// the line the cell draws instead, which selection cannot paint over. +// + +import AppKit +import CoreText +import Testing + +@testable import TablePro + +@Suite("Pending change marks") +@MainActor +struct DataGridPendingChangeMarkTests { + private let palette = DataGridCellPalette( + regularFont: .monospacedSystemFont(ofSize: 13, weight: .regular), + italicFont: .monospacedSystemFont(ofSize: 13, weight: .regular), + mediumFont: .monospacedSystemFont(ofSize: 13, weight: .medium), + text: .labelColor, + placeholderText: .secondaryLabelColor, + booleanTrueText: .labelColor, + booleanFalseText: .labelColor, + rowNumberText: .secondaryLabelColor, + deletedRowText: .systemRed, + modifiedColumnTint: .systemYellow, + findMatchTint: .systemOrange + ) + + private static let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) + private static let inserted = RowVisualState(isDeleted: false, isInserted: true, modifiedColumns: []) + private static let modified = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [1]) + + private func resolve( + text: String = "value", + visualState: RowVisualState = .empty, + columnIndex: Int = 0, + isCurrentFindMatch: Bool = false, + onEmphasizedSelection: Bool = false + ) -> DataGridCellAppearance { + DataGridCellAppearance.resolve( + kind: .text, + content: DataGridCellContent(displayText: text, rawValue: text, placeholder: nil), + state: DataGridCellState( + visualState: visualState, + isFocused: false, + isEditable: true, + isLargeDataset: false, + isCurrentFindMatch: isCurrentFindMatch, + row: 0, + columnIndex: columnIndex + ), + palette: palette, + nullDisplayString: "NULL", + onEmphasizedSelection: onEmphasizedSelection, + hasOverlay: false + ) + } + + // MARK: - Which mark a state carries + + @Test("A row staged for deletion is struck through, a new row underlined") + func rowMarks() { + #expect(resolve(visualState: Self.deleted).textMark == .struckThrough) + #expect(resolve(visualState: Self.inserted).textMark == .underlined) + #expect(resolve().textMark == nil) + } + + @Test("Only the edited cell of an edited row is underlined") + func modifiedColumnOnly() { + #expect(resolve(visualState: Self.modified, columnIndex: 1).textMark == .underlined) + #expect(resolve(visualState: Self.modified, columnIndex: 0).textMark == nil) + } + + /// The row is going away whatever was typed into it first, so one line wins rather than two + /// crossing each other. + @Test("A deleted row that was edited first keeps the strike alone") + func deleteOutranksTheEdit() { + let state = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: [1]) + + #expect(resolve(visualState: state, columnIndex: 1).textMark == .struckThrough) + } + + /// The whole point: the tint stands down under a selection and the find highlight, and the mark + /// does not. + @Test("The mark survives a selection and a find match, where the tint does not") + func markSurvivesWhatHidesTheTint() { + let selected = resolve(visualState: Self.modified, columnIndex: 1, onEmphasizedSelection: true) + let found = resolve(visualState: Self.modified, columnIndex: 1, isCurrentFindMatch: true) + let deletedAndSelected = resolve(visualState: Self.deleted, onEmphasizedSelection: true) + + #expect(selected.backgroundTint == nil) + #expect(selected.textMark == .underlined) + #expect(found.backgroundTint == palette.findMatchTint) + #expect(found.textMark == .underlined) + #expect(deletedAndSelected.textMark == .struckThrough) + } + + // MARK: - What a client is told + + @Test("A pending change names itself, and distinguishes a new row from an edited value") + func accessibilityDescriptions() { + #expect(DataGridCellTextMark.accessibilityDescription(state: Self.deleted, columnIndex: 0) != nil) + #expect( + DataGridCellTextMark.accessibilityDescription(state: Self.inserted, columnIndex: 0) + != DataGridCellTextMark.accessibilityDescription(state: Self.modified, columnIndex: 1) + ) + #expect(DataGridCellTextMark.accessibilityDescription(state: Self.modified, columnIndex: 0) == nil) + #expect(DataGridCellTextMark.accessibilityDescription(state: .empty, columnIndex: 0) == nil) + } + + // MARK: - What is actually drawn + + /// `CTLineDraw` draws both lines itself from the font's metrics, so these rasterise a cell and + /// read the pixels rather than trusting the attribute to have any effect. + /// + /// Drawn over mid grey, not white: a selected cell's text is + /// `alternateSelectedControlTextColor`, which is white, and a white-on-white cell rasterises + /// blank whatever it drew. Ink is any pixel that differs from the ground. + private func inkedColumns(of appearance: DataGridCellAppearance, in rect: NSRect) throws -> [[Int]] { + let host = CellHost(frame: rect) + host.cellAppearance = appearance + let rep = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + host.cacheDisplay(in: host.bounds, to: rep) + } + + let scale = rep.pixelsWide / Int(rect.width) + return (0 ..< rep.pixelsHigh).map { y in + (0 ..< rep.pixelsWide).filter { x in + guard let pixel = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return false } + return abs(pixel.redComponent - 0.5) > 0.1 + || abs(pixel.greenComponent - 0.5) > 0.1 + || abs(pixel.blueComponent - 0.5) > 0.1 + }.map { $0 / max(scale, 1) } + } + } + + /// Flipped, because the grid's own cell surface is and the renderer positions its baseline from + /// the top edge. Rasterising into an unflipped context draws the cell upside down, which is how + /// a first pass at these tests came to read the underline as sitting above the strike. + /// + /// Mid grey, not white: a selected cell's text is `alternateSelectedControlTextColor`, which is + /// white, and a white-on-white cell rasterises blank whatever it drew. + private final class CellHost: NSView { + var cellAppearance: DataGridCellAppearance? + + override var isFlipped: Bool { true } + + static let ground = NSColor(calibratedWhite: 0.5, alpha: 1) + + override func draw(_ dirtyRect: NSRect) { + Self.ground.setFill() + bounds.fill() + guard let cellAppearance else { return } + MainActor.assumeIsolated { DataGridCellRenderer().draw(cellAppearance, in: bounds) } + } + } + + private static let cell = NSRect(x: 0, y: 0, width: 160, height: 24) + + /// Where the mark landed: the row whose ink the mark adds over the same cell drawn unmarked, how + /// much it added, and how far right its own ink reaches. A glyph row gains nothing, so the + /// largest gain is the rule itself. The gain undercounts the rule's length wherever it crosses a + /// glyph the plain cell already inked, which is why reach is measured separately. + private func markedRow( + _ marked: DataGridCellAppearance, + against plain: DataGridCellAppearance, + in rect: NSRect = DataGridPendingChangeMarkTests.cell + ) throws -> (row: Int, gain: Int, reach: Int) { + let markedRows = try inkedColumns(of: marked, in: rect) + let plainRows = try inkedColumns(of: plain, in: rect) + let gains = zip(markedRows, plainRows).map { $0.count - $1.count } + let row = try #require(gains.indices.max(by: { gains[$0] < gains[$1] })) + return (row, gains[row], markedRows[row].max() ?? 0) + } + + @Test("A struck-through cell draws a rule its plain twin does not, selected or not") + func strikeIsDrawn() throws { + let text = "0123456789" + let textWidth = Int(text.size(withAttributes: [.font: palette.regularFont]).width) + + let struck = try markedRow(resolve(text: text, visualState: Self.deleted), against: resolve(text: text)) + let selected = try markedRow( + resolve(text: text, visualState: Self.deleted, onEmphasizedSelection: true), + against: resolve(text: text, onEmphasizedSelection: true) + ) + + #expect(struck.gain > textWidth / 2) + #expect(selected.reach > textWidth / 2) + } + + /// The two lines have to land in different places, or they are the same cue twice. + @Test("The underline sits below the strike") + func underlineSitsBelowTheStrike() throws { + let text = "0123456789" + let plain = resolve(text: text) + let struck = try markedRow(resolve(text: text, visualState: Self.deleted), against: plain) + let underlined = try markedRow(resolve(text: text, visualState: Self.inserted), against: plain) + + #expect(underlined.row > struck.row) + } + + /// A cell too narrow for its value truncates, and the mark has to stop where the text does + /// rather than run on across the rest of the cell. + @Test("The mark reaches as far as the text and no further, truncated or not") + func markStopsWithTheText() throws { + let short = "0123" + let inset = DataGridMetrics.cellHorizontalInset + let textEnd = inset + short.size(withAttributes: [.font: palette.regularFont]).width + let roomy = NSRect(x: 0, y: 0, width: 300, height: 24) + let shortMark = try markedRow( + resolve(text: short, visualState: Self.deleted), + against: resolve(text: short), + in: roomy + ) + + #expect(CGFloat(shortMark.reach) <= textEnd + 2) + #expect(CGFloat(shortMark.reach) > textEnd - 4) + + let long = String(repeating: "0123456789", count: 8) + let narrow = NSRect(x: 0, y: 0, width: 120, height: 24) + let truncatedMark = try markedRow( + resolve(text: long, visualState: Self.deleted), + against: resolve(text: long), + in: narrow + ) + + #expect(CGFloat(truncatedMark.reach) <= narrow.width) + #expect(truncatedMark.reach > shortMark.reach) + + /// Reach alone cannot see this: the glyphs of a truncated value already run to the cell's + /// edge, so a line that lost its mark reaches just as far. Only the gain separates them, + /// measured at 158 with the rule against 67 without it, beside the short cell's 50. So the + /// rule carried onto the truncated line is asserted where a regression would actually show. + #expect(truncatedMark.gain > shortMark.gain * 2) + } +} diff --git a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift index 8935e3d234..b060cd2c37 100644 --- a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift +++ b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift @@ -74,6 +74,22 @@ private struct Raster { } } +/// A band above the grid, the shape the window itself has: the find bar, the filter panel, the +/// key-pattern search bar, the result tab bar and the banners are all siblings stacked above the +/// data grid, and every one of them is an earlier sibling, so anything the grid paints outside +/// itself lands on top of them. +@MainActor +private final class StripHost: NSView { + static let color = NSColor(srgbRed: 0.15, green: 0.45, blue: 0.85, alpha: 1) + + override var isFlipped: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + Self.color.setFill() + dirtyRect.fill() + } +} + private func matches(_ lhs: NSColor, _ rhs: NSColor, tolerance: CGFloat = 0.02) -> Bool { abs(lhs.redComponent - rhs.redComponent) <= tolerance && abs(lhs.greenComponent - rhs.greenComponent) <= tolerance @@ -87,6 +103,7 @@ struct DataGridRowNumberRenderingTests { @MainActor private struct Grid { let window: NSWindow + let host: StripHost? let scrollView: NSScrollView let tableView: KeyHandlingTableView let header: SortableHeaderView @@ -103,6 +120,15 @@ struct DataGridRowNumberRenderingTests { scrollView.layoutSubtreeIfNeeded() } + /// Far enough down that the strip's own drawing reaches past the top of the viewport, which + /// is the offset the escape shows at. + func scrollVertically(toRow row: Int) { + tableView.scroll(NSPoint(x: scrollView.contentView.bounds.origin.x, y: tableView.rect(ofRow: row).minY)) + scrollView.layoutSubtreeIfNeeded() + gutter.synchronizeGeometry() + gutter.displayIfNeeded() + } + func pointInScrollView(_ point: NSPoint) -> NSPoint { scrollView.convert(point, from: tableView) } @@ -128,7 +154,8 @@ struct DataGridRowNumberRenderingTests { private func makeGrid( titles: [String] = (0..<8).map { "column\($0)" }, rows: Int = DataGridRowNumberRenderingTests.rowCount, - appearance: NSAppearance.Name = .darkAqua + appearance: NSAppearance.Name = .darkAqua, + stripAbove: CGFloat = 0 ) -> Grid { let coordinator = TableViewCoordinator( changeManager: AnyChangeManager(DataChangeManager()), @@ -176,7 +203,7 @@ struct DataGridRowNumberRenderingTests { widthCalculator: { _, _ in Self.columnWidth } ) - let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + let scrollView = NSScrollView(frame: NSRect(x: 0, y: stripAbove, width: 600, height: 300 - stripAbove)) scrollView.hasHorizontalScroller = true scrollView.hasVerticalScroller = true scrollView.documentView = tableView @@ -187,13 +214,28 @@ struct DataGridRowNumberRenderingTests { defer: false ) window.appearance = NSAppearance(named: appearance) - window.contentView = scrollView - let gutter = DataGridRowGutterView(frame: .zero) - gutter.coordinator = coordinator - tableView.addSubview(gutter) - scrollView.addFloatingSubview(gutter, for: .horizontal) - coordinator.rowGutter = gutter + var host: StripHost? + if stripAbove > 0 { + let stripHost = StripHost(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + stripHost.addSubview(scrollView) + window.contentView = stripHost + host = stripHost + } else { + window.contentView = scrollView + } + + let gutter: DataGridRowGutterView + if stripAbove > 0 { + DataGridView.installRowGutter(scrollView: scrollView, tableView: tableView, coordinator: coordinator) + gutter = coordinator.rowGutter ?? DataGridRowGutterView(frame: .zero) + } else { + gutter = DataGridRowGutterView(frame: .zero) + gutter.coordinator = coordinator + tableView.addSubview(gutter) + scrollView.addFloatingSubview(gutter, for: .horizontal) + coordinator.rowGutter = gutter + } tableView.reloadData() tableView.layoutSubtreeIfNeeded() @@ -205,6 +247,7 @@ struct DataGridRowNumberRenderingTests { } return Grid( window: window, + host: host, scrollView: scrollView, tableView: tableView, header: header, @@ -449,6 +492,80 @@ struct DataGridRowNumberRenderingTests { #expect(grid.header.drawnPinnedHeadingRect == widened) } + /// `NSTableRowView` paints the system stripes whatever the table is told, so a theme's own pair + /// only shows because the row paints the stripe `DataGridBodyChrome` gives it. The strip reads the + /// same owner, so it has to keep matching the row under a theme too. Light mode, where the + /// system stripes are white and light grey, so a dark themed row cannot pass for one. + @Test("Rows paint the theme's stripes, and the strip still matches them") + func rowsPaintTheThemesStripes() throws { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + var theme = BuiltInThemes.light + theme.id = "test.grid-stripes" + theme.dataGrid.background = .hex("#282A36") + theme.dataGrid.alternateRow = .hex("#44475A") + engine.adopt(ThemeSelection( + pair: ThemePair(light: theme, dark: BuiltInThemes.dark), + effectiveAppearance: .light + )) + + let grid = makeGrid(appearance: .aqua) + let rows = grid.rowsUnderTheStrip + #expect(rows.count >= 4, "too few rows under the strip to cover both stripes") + let firstData = try #require(grid.coordinator.firstPresentedColumnIndex()) + let dataRect = grid.tableView.rect(ofColumn: firstData) + let raster = try #require(Raster(of: grid.scrollView, in: grid.scrollView.bounds)) + + var bodies: [Int: NSColor] = [:] + for row in rows { + let midY = grid.tableView.rect(ofRow: row).midY + let strip = try #require(raster.color(at: grid.pointInScrollView(NSPoint(x: 2, y: midY)))) + let body = try #require( + raster.color(at: grid.pointInScrollView(NSPoint(x: dataRect.maxX - 12, y: midY))) + ) + #expect(matches(strip, body), "row \(row): strip \(strip), row \(body)") + #expect(body.redComponent < 0.35, "row \(row) drew \(body), not the theme's dark stripe") + bodies[row] = body + } + let even = try #require(rows.first(where: { $0.isMultiple(of: 2) }).flatMap { bodies[$0] }) + let odd = try #require(rows.first(where: { !$0.isMultiple(of: 2) }).flatMap { bodies[$0] }) + #expect(!matches(even, odd), "both stripes drew \(even)") + } + + // MARK: - Staying inside the grid + + private static let stripHeight: CGFloat = 40 + + /// The strip is document-tall, so without a clip it paints far past the viewport it pins. On + /// screen that put row numbers and the strip's column separator over the find bar, and over + /// every other sibling the window stacks above the grid. + @Test("The pinned strip paints nothing above the grid") + func stripPaintsNothingAboveTheGrid() throws { + let grid = makeGrid(appearance: .aqua, stripAbove: Self.stripHeight) + let host = try #require(grid.host) + grid.scrollVertically(toRow: 6) + + let raster = try #require(Raster(of: host, in: host.bounds)) + /// The band's own colour where the strip cannot reach, rather than `StripHost.color`: the + /// cached drawing lands in an HDR colour space that shifts the fill, so the only reliable + /// reference is the same band measured beside the part under test. + let untouched = try #require(raster.color(at: NSPoint(x: host.bounds.maxX - 40, y: 4))) + let leadingEdge = NSRect(x: 0, y: 0, width: 120, height: Self.stripHeight) + #expect( + raster.inkPixels(in: leadingEdge, unlike: untouched) == 0, + "the grid painted above itself" + ) + } + + /// What holds the strip in: nothing between a floating subview and the window frame clips on its + /// own, and the scroll view is the view the grid owns. + @Test("The grid's scroll view clips to its bounds") + func scrollViewClipsToBounds() { + let grid = makeGrid(appearance: .aqua, stripAbove: Self.stripHeight) + #expect(grid.scrollView.clipsToBounds) + } + private func rightClick(at point: NSPoint, in grid: Grid) -> NSEvent? { NSEvent.mouseEvent( with: .rightMouseDown, diff --git a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift index 059bc25999..26e5ca3adc 100644 --- a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift +++ b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift @@ -4,9 +4,28 @@ // import AppKit +import SwiftUI @testable import TablePro import Testing +@MainActor +private final class FixedVisualStateDelegate: DataGridViewDelegate { + var state: RowVisualState + + init(state: RowVisualState) { + self.state = state + } + + func dataGridVisualState(forRow row: Int) -> RowVisualState? { state } +} + +@MainActor +private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + /// These tests activate a theme on the shared `ThemeEngine`. What keeps that from reaching a suite /// running in parallel is that both bodies are synchronous and `@MainActor`, so nothing else on the /// main actor can interleave between activating the test theme and restoring the original one. @@ -17,8 +36,29 @@ import Testing struct DataGridRowTintThemeTests { private static let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) - private func makeRowView() -> DataGridRowView { - DataGridRowView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + private final class Harness { + let delegate: FixedVisualStateDelegate + let coordinator: TableViewCoordinator + let rowView: DataGridRowView + + @MainActor + init(state: RowVisualState) { + delegate = FixedVisualStateDelegate(state: state) + coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: false, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: NoopColumnLayoutPersister() + ) + rowView = DataGridRowView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + rowView.coordinator = coordinator + rowView.rowIndex = 0 + } + } + + private func makeRowView(state: RowVisualState) -> Harness { + Harness(state: state) } private func renderedTint(of rowView: DataGridRowView) throws -> NSColor { @@ -28,46 +68,89 @@ struct DataGridRowTintThemeTests { return try #require(color.usingColorSpace(.sRGB)) } - private func theme(_ base: ThemeDefinition, id: String, deletedHex: String) -> ThemeDefinition { + private func selection(_ base: ThemeDefinition, id: String, deletedHex: String) -> ThemeSelection { var copy = base copy.id = id - copy.dataGrid.deleted = deletedHex - return copy + copy.dataGrid.deleted = .hex(deletedHex) + + let pair = copy.appearance == .dark + ? ThemePair(light: BuiltInThemes.light, dark: copy) + : ThemePair(light: copy, dark: BuiltInThemes.dark) + + return ThemeSelection(pair: pair, effectiveAppearance: copy.appearance) } @Test("A deleted row takes the new theme's tint even though its state did not change") func deletedRowTintFollowsThemeChange() throws { let engine = ThemeEngine.shared let original = engine.activeTheme - defer { engine.activateTheme(original) } + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } - engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) - let rowView = makeRowView() - rowView.applyVisualState(Self.deleted) - let firstTint = try renderedTint(of: rowView) + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + let harness = makeRowView(state: Self.deleted) + let firstTint = try renderedTint(of: harness.rowView) - engine.activateTheme(theme(original, id: "test.tint.blue", deletedHex: "#0000FF")) - rowView.applyVisualState(Self.deleted) - let secondTint = try renderedTint(of: rowView) + engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) + harness.rowView.invalidateVisualState() + let secondTint = try renderedTint(of: harness.rowView) #expect(firstTint.redComponent > secondTint.redComponent) #expect(secondTint.blueComponent > firstTint.blueComponent) } + @Test("A row paints the state its coordinator reports now, not one pushed into it earlier") + func rowPaintsTheLiveState() throws { + let engine = ThemeEngine.shared + let original = engine.activeTheme + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + + let harness = makeRowView(state: .empty) + let before = try renderedTint(of: harness.rowView) + harness.delegate.state = Self.deleted + let after = try renderedTint(of: harness.rowView) + + #expect(before.alphaComponent == 0) + #expect(after.redComponent > 0.5) + #expect(harness.rowView.visualState == Self.deleted) + } + + @Test("A pending delete keeps its wash over a matching highlight rule") + func pendingDeleteOutranksHighlightWash() throws { + let engine = ThemeEngine.shared + let original = engine.activeTheme + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + + let highlight = RowHighlight( + rowRule: HighlightRule(columnName: "status", value: "paid", color: .blue), + cellRules: [:] + ) + let harness = makeRowView(state: Self.deleted.highlighted(highlight)) + let tint = try renderedTint(of: harness.rowView) + + #expect(tint.redComponent > tint.blueComponent) + #expect(Self.deleted.highlighted(highlight).tint == engine.palette[.gridDeleted]) + #expect(RowVisualState.empty.highlighted(highlight).tint == HighlightColor.blue.washColor) + } + @Test("A row with no deleted or inserted state stays untinted across a theme change") func plainRowStaysUntinted() throws { let engine = ThemeEngine.shared let original = engine.activeTheme - defer { engine.activateTheme(original) } + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } - engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) - let rowView = makeRowView() - rowView.applyVisualState(.empty) - let firstTint = try renderedTint(of: rowView) + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + let harness = makeRowView(state: .empty) + let firstTint = try renderedTint(of: harness.rowView) - engine.activateTheme(theme(original, id: "test.tint.blue", deletedHex: "#0000FF")) - rowView.applyVisualState(.empty) - let secondTint = try renderedTint(of: rowView) + engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) + harness.rowView.invalidateVisualState() + let secondTint = try renderedTint(of: harness.rowView) #expect(firstTint.redComponent == secondTint.redComponent) #expect(firstTint.blueComponent == secondTint.blueComponent) diff --git a/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift b/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift index e37360eff5..d8a139b418 100644 --- a/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift +++ b/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift @@ -16,6 +16,7 @@ struct DataGridUpdateSnapshotTests { reloadVersion: Int = 0, contentRevision: Int = 0, displayFormats: [ValueDisplayFormat?] = [], + highlightRules: [HighlightRule] = [], columnComments: [String: String] = [:] ) -> DataGridUpdateSnapshot { DataGridUpdateSnapshot( @@ -24,6 +25,7 @@ struct DataGridUpdateSnapshotTests { columns: columns, valueFilteredIDsCount: nil, displayFormats: displayFormats, + highlightRules: highlightRules, configuration: DataGridConfiguration(), isEditable: true, rowReorder: .disabled, @@ -79,6 +81,20 @@ struct DataGridUpdateSnapshotTests { #expect(raw != uuid) } + @Test("A highlight rule change invalidates the update snapshot") + func highlightRuleChangesSnapshot() { + let rule = HighlightRule(columnName: "type", value: "admin", color: .green) + var recolored = rule + recolored.color = .red + + let highlighted = makeSnapshot(highlightRules: [rule]) + let rebuilt = makeSnapshot(highlightRules: [rule]) + + #expect(makeSnapshot() != highlighted) + #expect(highlighted != makeSnapshot(highlightRules: [recolored])) + #expect(highlighted == rebuilt) + } + @Test("Display format cache entries are scoped to a pinned result set") func displayFormatCacheUsesResultSetIdentity() { let firstResult = UUID() diff --git a/TableProTests/Views/Results/Extensions/FillColumnTests.swift b/TableProTests/Views/Results/Extensions/FillColumnTests.swift index 35d0fcba7d..ebd34fe1ad 100644 --- a/TableProTests/Views/Results/Extensions/FillColumnTests.swift +++ b/TableProTests/Views/Results/Extensions/FillColumnTests.swift @@ -117,21 +117,21 @@ struct FillColumnTests { coordinator.applyFillColumn(columnIndex: 0, value: .text("X")) for row in 0..<4 { - #expect(manager.pending.isCellModified(rowIndex: row, columnIndex: 0)) + #expect(manager.pending.isCellModified(rowID: .existing(row), columnIndex: 0)) } - #expect(manager.pending.isCellModified(rowIndex: 0, columnIndex: 1) == false) + #expect(manager.pending.isCellModified(rowID: .existing(0), columnIndex: 1) == false) } @Test("Does not touch rows marked for deletion") func skipsDeletedRowsOnApply() { let manager = DataChangeManager() let coordinator = makeCoordinator(columns: ["a"], rowCount: 4, manager: manager) - manager.recordRowDeletion(rowIndex: 2, originalRow: [.text("r2c0")]) + manager.recordRowDeletion(rowID: .existing(2), originalRow: [.text("r2c0")]) coordinator.applyFillColumn(columnIndex: 0, value: .text("X")) - #expect(manager.pending.isCellModified(rowIndex: 0, columnIndex: 0)) - #expect(manager.pending.isCellModified(rowIndex: 2, columnIndex: 0) == false) + #expect(manager.pending.isCellModified(rowID: .existing(0), columnIndex: 0)) + #expect(manager.pending.isCellModified(rowID: .existing(2), columnIndex: 0) == false) } @Test("Records nothing on a read-only result set") @@ -151,7 +151,7 @@ struct FillColumnTests { coordinator.applyFillColumn(columnIndex: 0, value: .null) - let change = manager.pending.change(forRow: 0, type: .update) + let change = manager.pending.change(forRow: .existing(0), type: .update) #expect(change?.cellChanges.first?.newValue == .null) } @@ -162,7 +162,7 @@ struct FillColumnTests { coordinator.applyFillColumn(columnIndex: 0, value: .text("r0c0")) - #expect(manager.pending.isCellModified(rowIndex: 0, columnIndex: 0) == false) - #expect(manager.pending.isCellModified(rowIndex: 1, columnIndex: 0)) + #expect(manager.pending.isCellModified(rowID: .existing(0), columnIndex: 0) == false) + #expect(manager.pending.isCellModified(rowID: .existing(1), columnIndex: 0)) } } diff --git a/TableProTests/Views/Results/ResultsJsonViewTests.swift b/TableProTests/Views/Results/ResultsJsonViewTests.swift index 3d49723d58..abdb136211 100644 --- a/TableProTests/Views/Results/ResultsJsonViewTests.swift +++ b/TableProTests/Views/Results/ResultsJsonViewTests.swift @@ -32,14 +32,14 @@ struct ResultsJsonViewTests { private func compute( displayIDs: [RowID]? = nil, selectedIndices: Set, - deletedIndices: Set = [], + deletedRowIDs: Set = [], columnLayout: ColumnLayoutState = ColumnLayoutState() ) -> ResultsJsonView.RenderedJson { ResultsJsonView.computeJson( tableRows: makeTableRows(), displayIDs: displayIDs, selectedIndices: selectedIndices, - deletedIndices: deletedIndices, + deletedRowIDs: deletedRowIDs, columnLayout: columnLayout ) } @@ -78,7 +78,7 @@ struct ResultsJsonViewTests { @Test("a row marked for deletion is left out of the document") func pendingDeletionIsExcluded() { - let result = compute(selectedIndices: [], deletedIndices: [1]) + let result = compute(selectedIndices: [], deletedRowIDs: [.existing(1)]) #expect(result.resolvedCount == 3) #expect(!result.json.contains("\"b\"")) @@ -88,16 +88,18 @@ struct ResultsJsonViewTests { @Test("a row marked for deletion is left out even when it is part of the selection") func pendingDeletionIsExcludedFromASelection() { - let result = compute(selectedIndices: [0, 1], deletedIndices: [1]) + let result = compute(selectedIndices: [0, 1], deletedRowIDs: [.existing(1)]) #expect(result.resolvedCount == 1) #expect(result.json.contains("\"a\"")) #expect(!result.json.contains("\"b\"")) } - @Test("deletion positions are display positions, resolved through the display order") - func pendingDeletionUsesDisplayPositions() { - let result = compute(displayIDs: [.existing(2), .existing(0)], selectedIndices: [], deletedIndices: [0]) + @Test("a deletion stays on its row whatever position the display order gives it") + func pendingDeletionFollowsItsRow() { + let result = compute( + displayIDs: [.existing(2), .existing(0)], selectedIndices: [], deletedRowIDs: [.existing(2)] + ) #expect(result.resolvedCount == 1) #expect(result.json.contains("\"a\"")) diff --git a/TableProTests/Views/Results/RowVisualIndexTests.swift b/TableProTests/Views/Results/RowVisualIndexTests.swift index 43165366cf..a9126c82d7 100644 --- a/TableProTests/Views/Results/RowVisualIndexTests.swift +++ b/TableProTests/Views/Results/RowVisualIndexTests.swift @@ -4,35 +4,87 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro -@Suite("RowVisualIndex display-order mapping") +@Suite("RowVisualIndex row identity") @MainActor struct RowVisualIndexTests { - @Test("an inserted row is marked at its display index, not its model index") - func insertedRowMarkedAtDisplayIndex() { + private func makeManager() -> DataChangeManager { + let manager = DataChangeManager() + manager.configureForTable( + tableName: "users", + columns: ["id", "name"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + return manager + } + + @Test("An inserted row is marked on its own row id") + func insertedRowMarkedOnItsID() { + let manager = makeManager() + let inserted = RowID.inserted(UUID()) + manager.recordRowInsertion(rowID: inserted, values: [.null, .null]) + let index = RowVisualIndex() + + index.rebuild(from: AnyChangeManager(manager)) + + #expect(index.visualState(for: inserted).isInserted) + #expect(!index.visualState(for: .existing(0)).isInserted) + } + + @Test("Edits and deletions are marked on the rows they were made to") + func editsAndDeletionsFollowTheirRows() { + let manager = makeManager() + manager.recordCellChange( + rowID: .existing(7), columnIndex: 1, columnName: "name", + oldValue: "Ann", newValue: "Bea", originalRow: ["7", "Ann"] + ) + manager.recordRowDeletion(rowID: .existing(3), originalRow: ["3", "Cy"]) let index = RowVisualIndex() - let changeManager = AnyChangeManager(DataChangeManager()) - let displayIDs: [RowID] = [.existing(0), .inserted(UUID()), .existing(2)] - index.rebuild(from: changeManager, displayIDs: displayIDs) + index.rebuild(from: AnyChangeManager(manager)) + + #expect(index.visualState(for: .existing(7)).modifiedColumns == [1]) + #expect(!index.visualState(for: .existing(7)).isDeleted) + #expect(index.visualState(for: .existing(3)).isDeleted) + #expect(index.visualState(for: .existing(0)) == .empty) + } + + @Test("Updating one row picks up its latest state and leaves the others alone") + func updateRowRefreshesOneRow() { + let manager = makeManager() + let changeManager = AnyChangeManager(manager) + manager.recordRowDeletion(rowID: .existing(1), originalRow: ["1", "A"]) + let index = RowVisualIndex() + index.rebuild(from: changeManager) + + manager.recordCellChange( + rowID: .existing(2), columnIndex: 1, columnName: "name", + oldValue: "B", newValue: "C", originalRow: ["2", "B"] + ) + index.updateRow(.existing(2), from: changeManager) + + #expect(index.visualState(for: .existing(2)).modifiedColumns == [1]) + #expect(index.visualState(for: .existing(1)).isDeleted) + + manager.undoRowDeletion(rowID: .existing(1)) + index.updateRow(.existing(1), from: changeManager) - #expect(index.visualState(for: 1).isInserted) - #expect(!index.visualState(for: 0).isInserted) - #expect(!index.visualState(for: 2).isInserted) + #expect(index.visualState(for: .existing(1)) == .empty) } - @Test("no inserted rows leaves every display index clean") - func noInsertedRowsLeavesStateEmpty() { + @Test("No changes leaves every row clean") + func noChangesLeavesStateEmpty() { let index = RowVisualIndex() - let changeManager = AnyChangeManager(DataChangeManager()) - let displayIDs: [RowID] = [.existing(0), .existing(1)] - index.rebuild(from: changeManager, displayIDs: displayIDs) + index.rebuild(from: AnyChangeManager(makeManager())) - #expect(index.visualState(for: 0) == .empty) - #expect(index.visualState(for: 1) == .empty) + #expect(index.visualState(for: .existing(0)) == .empty) + #expect(index.visualState(for: .inserted(UUID())) == .empty) } } diff --git a/TableProTests/Views/Results/TableViewCoordinatorFindTests.swift b/TableProTests/Views/Results/TableViewCoordinatorFindTests.swift index 2536b48be1..43aceaa63e 100644 --- a/TableProTests/Views/Results/TableViewCoordinatorFindTests.swift +++ b/TableProTests/Views/Results/TableViewCoordinatorFindTests.swift @@ -32,7 +32,7 @@ struct TableViewCoordinatorFindTests { columnTypes: [.blob(rawType: "VARBINARY(255)")] ) coordinator.tableRowsProvider = { captured } - coordinator.tableRowsMutator = { (mutation: (inout TableRows) -> Void) in mutation(&captured) } + coordinator.tableRowsMutator = { (mutation: (inout TableRows) -> Delta) in mutation(&captured) } coordinator.updateCache() coordinator.updateDisplayFormats([format]) return coordinator diff --git a/TableProTests/Views/Results/TableViewCoordinatorHighlightTests.swift b/TableProTests/Views/Results/TableViewCoordinatorHighlightTests.swift new file mode 100644 index 0000000000..dd57f78c17 --- /dev/null +++ b/TableProTests/Views/Results/TableViewCoordinatorHighlightTests.swift @@ -0,0 +1,145 @@ +// +// TableViewCoordinatorHighlightTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class HighlightTestPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class StructureStateDelegate: DataGridViewDelegate { + func dataGridVisualState(forRow row: Int) -> RowVisualState? { .empty } +} + +@MainActor +private final class HighlightGrid { + var tableRows: TableRows + let coordinator: TableViewCoordinator + + init(statuses: [String], delegate: (any DataGridViewDelegate)? = nil) { + let rows = ContiguousArray(statuses.enumerated().map { index, status in + Row(id: .existing(index), values: [.text("\(index)"), .text(status)]) + }) + tableRows = TableRows( + rows: rows, + columns: ["id", "status"], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "VARCHAR")] + ) + coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: HighlightTestPersister() + ) + coordinator.tableRowsProvider = { [weak self] in self?.tableRows ?? TableRows() } + coordinator.tableRowsMutator = { [weak self] mutation in + guard let self else { return .none } + return mutation(&self.tableRows) + } + coordinator.rebuildColumnMetadataCache(from: tableRows) + coordinator.updateCache() + } + + @discardableResult + func apply(_ rules: [HighlightRule]) -> Bool { + coordinator.syncHighlightRules(rules, tableRows: tableRows) + } + + func rowColor(_ row: Int) -> HighlightColor? { + coordinator.visualState(for: row).highlight.rowColor + } +} + +@Suite("Grid coordinator highlight rules") +@MainActor +struct TableViewCoordinatorHighlightTests { + private let paid = HighlightRule(columnName: "status", value: "paid", color: .green) + + @Test("Only the rows a rule matches carry its highlight") + func matchingRowsCarryTheHighlight() { + let grid = HighlightGrid(statuses: ["paid", "pending", "paid"]) + grid.apply([paid]) + + #expect(grid.rowColor(0) == .green) + #expect(grid.rowColor(1) == nil) + #expect(grid.rowColor(2) == .green) + } + + @Test("Changing the rules reports a change and recolours rows already evaluated") + func changingRulesRecolours() { + let grid = HighlightGrid(statuses: ["paid"]) + #expect(grid.apply([paid])) + #expect(grid.rowColor(0) == .green) + + var recolored = paid + recolored.color = .red + #expect(grid.apply([recolored])) + #expect(grid.rowColor(0) == .red) + #expect(!grid.apply([recolored])) + } + + @Test("An edit that flips a rule is reflected once the edit commits") + func editFlipsTheHighlight() { + let grid = HighlightGrid(statuses: ["pending"]) + grid.apply([paid]) + #expect(grid.rowColor(0) == nil) + + grid.coordinator.commitTypedCellEdit(row: 0, columnIndex: 1, newValue: .text("paid")) + + #expect(grid.tableRows.rows[0].values[1] == .text("paid")) + #expect(grid.rowColor(0) == .green) + } + + @Test("New rows under the same positional ids are evaluated afresh once the cache is dropped") + func positionalIdsAreNotServedStaleHighlights() { + let grid = HighlightGrid(statuses: ["paid"]) + grid.apply([paid]) + #expect(grid.rowColor(0) == .green) + + grid.tableRows.rows[0].values[1] = .text("pending") + grid.coordinator.invalidateDisplayCache() + + #expect(grid.rowColor(0) == nil) + } + + @Test("A fresh display state for a new page carries no highlights from the old one") + func freshDisplayStateStartsClean() { + let grid = HighlightGrid(statuses: ["paid"]) + grid.apply([paid]) + #expect(grid.rowColor(0) == .green) + + grid.tableRows.rows[0].values[1] = .text("pending") + grid.coordinator.adoptDisplayState(DataGridDisplayState()) + grid.apply([paid]) + + #expect(grid.rowColor(0) == nil) + } + + @Test("A grid whose owner supplies its own row state is never highlighted") + func delegateStateSuppressesHighlights() { + let delegate = StructureStateDelegate() + let grid = HighlightGrid(statuses: ["paid"], delegate: delegate) + grid.apply([paid]) + + #expect(grid.rowColor(0) == nil) + } + + @Test("The accessibility description names the rule that coloured the cell") + func accessibilityDescription() { + let grid = HighlightGrid(statuses: ["paid"]) + grid.apply([paid]) + + #expect(grid.coordinator.highlightDescription(row: 0, columnIndex: 0) == "status = “paid”") + } +} diff --git a/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift b/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift index cd678c42b1..d3f5c4ccb8 100644 --- a/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift +++ b/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift @@ -45,9 +45,9 @@ struct TableViewCoordinatorRowCountCacheTests { func updateCacheReflectsAppendedRows() { let coordinator = makeCoordinator(rows: []) - coordinator.tableRowsMutator { rows in + _ = coordinator.tableRowsMutator { rows in _ = rows.appendInsertedRow(values: [.text("a")]) - _ = rows.appendInsertedRow(values: [.text("b")]) + return rows.appendInsertedRow(values: [.text("b")]) } coordinator.updateCache() @@ -64,8 +64,8 @@ struct TableViewCoordinatorRowCountCacheTests { ] let coordinator = makeCoordinator(rows: rows) - coordinator.tableRowsMutator { rows in - _ = rows.remove(rowIDs: [.existing(1)]) + _ = coordinator.tableRowsMutator { rows in + rows.remove(rowIDs: [.existing(1)]) } coordinator.updateCache() @@ -80,8 +80,8 @@ struct TableViewCoordinatorRowCountCacheTests { ] let coordinator = makeCoordinator(rows: rows) - coordinator.tableRowsMutator { rows in - _ = rows.replace(rows: [[.text("x")], [.text("y")], [.text("z")], [.text("w")]]) + _ = coordinator.tableRowsMutator { rows in + rows.replace(rows: [[.text("x")], [.text("y")], [.text("z")], [.text("w")]]) } coordinator.updateCache() diff --git a/TableProTests/Views/Results/TableViewCoordinatorRowIdentityTests.swift b/TableProTests/Views/Results/TableViewCoordinatorRowIdentityTests.swift new file mode 100644 index 0000000000..779d4ed2a4 --- /dev/null +++ b/TableProTests/Views/Results/TableViewCoordinatorRowIdentityTests.swift @@ -0,0 +1,177 @@ +// +// TableViewCoordinatorRowIdentityTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class NoopRowIdentityLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class RowStore { + var tableRows: TableRows + + init(_ tableRows: TableRows) { + self.tableRows = tableRows + } +} + +@Suite("TableViewCoordinator row identity") +@MainActor +struct TableViewCoordinatorRowIdentityTests { + private func makeManager() -> DataChangeManager { + let manager = DataChangeManager() + manager.configureForTable( + tableName: "users", + columns: ["id", "status"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + return manager + } + + private func makeCoordinator(manager: DataChangeManager) -> (TableViewCoordinator, RowStore) { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(manager), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: NoopRowIdentityLayoutPersister() + ) + let store = RowStore(TableRows.from( + queryRows: [ + [.text("1"), .text("active")], + [.text("2"), .text("inactive")], + [.text("3"), .text("active")], + [.text("4"), .text("inactive")] + ], + columns: ["id", "status"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + )) + coordinator.tableRowsProvider = { store.tableRows } + coordinator.tableRowsMutator = { mutation in mutation(&store.tableRows) } + coordinator.updateCache() + return (coordinator, store) + } + + private func filterToInactive(_ coordinator: TableViewCoordinator) { + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["inactive"], includesNull: false), + columnName: "status", + forColumn: 1 + ) + } + + @Test("An edit under a value filter is recorded on the row shown, and only that row changes") + func editUnderFilterLandsOnTheShownRow() throws { + let manager = makeManager() + let (coordinator, store) = makeCoordinator(manager: manager) + filterToInactive(coordinator) + #expect(coordinator.displayIDs == [.existing(1), .existing(3)]) + + coordinator.recordCellEdit(row: 1, columnIndex: 1, newValue: .text("archived")) + + #expect(manager.isCellModified(rowID: .existing(3), columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(1), columnIndex: 1)) + #expect(store.tableRows.rows[3].values[1] == "archived") + #expect(store.tableRows.rows[1].values[1] == "inactive") + + let statements = try manager.generateSQL() + let parameters = statements.first?.parameters ?? [] + #expect(statements.count == 1) + #expect(parameters.count == 2) + #expect(parameters.last.flatMap { $0 as? String } == "4") + } + + @Test("An edit keeps its row when the filter is cleared and positions move") + func editFollowsItsRowAcrossADisplayOrderChange() { + let manager = makeManager() + let (coordinator, _) = makeCoordinator(manager: manager) + filterToInactive(coordinator) + + coordinator.recordCellEdit(row: 0, columnIndex: 1, newValue: .text("archived")) + coordinator.clearAllValueFilters() + + #expect(coordinator.displayIDs == nil) + #expect(coordinator.visualState(for: 1).modifiedColumns == [1]) + #expect(coordinator.visualState(for: 0).modifiedColumns.isEmpty) + } + + @Test("Undoing an edit made under a value filter reverts the row that was edited") + func undoUnderFilterRevertsTheEditedRow() { + let manager = makeManager() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + manager.undoManagerProvider = { undoManager } + var captured: UndoResult? + manager.onUndoApplied = { captured = $0 } + let (coordinator, store) = makeCoordinator(manager: manager) + filterToInactive(coordinator) + + coordinator.recordCellEdit(row: 1, columnIndex: 0, newValue: .text("9")) + #expect(store.tableRows.rows[3].values[0] == "9") + + undoManager.undo() + guard let captured else { + Issue.record("The undo produced no result") + return + } + _ = RowOperationsManager(changeManager: manager).applyUndoResult(captured, tableRows: &store.tableRows) + + #expect(store.tableRows.rows[3].values[0] == "4") + #expect(store.tableRows.rows[1].values[0] == "2") + #expect(!manager.isCellModified(rowID: .existing(3), columnIndex: 0)) + } + + @Test("A deleted row is recognised at whatever position the filter shows it") + func deletedRowIsFoundByIdentity() { + let manager = makeManager() + let (coordinator, _) = makeCoordinator(manager: manager) + manager.recordRowDeletion(rowID: .existing(3), originalRow: ["4", "inactive"]) + filterToInactive(coordinator) + + #expect(coordinator.isRowDeleted(displayRow: 1)) + #expect(!coordinator.isRowDeleted(displayRow: 0)) + #expect(coordinator.visualState(for: 1).isDeleted) + } + + @Test("Fill Column under a filter writes the shown rows and skips a deleted one") + func fillColumnUnderFilterTargetsShownRows() { + let manager = makeManager() + let (coordinator, _) = makeCoordinator(manager: manager) + filterToInactive(coordinator) + manager.recordRowDeletion(rowID: .existing(3), originalRow: ["4", "inactive"]) + + coordinator.applyFillColumn(columnIndex: 1, value: .text("gone")) + + #expect(manager.isCellModified(rowID: .existing(1), columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(3), columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(0), columnIndex: 1)) + #expect(!manager.isCellModified(rowID: .existing(2), columnIndex: 1)) + } + + @Test("Undo Delete from the row menu clears the row shown at that position") + func undoDeleteUnderFilterClearsTheShownRow() { + let manager = makeManager() + let (coordinator, _) = makeCoordinator(manager: manager) + manager.recordRowDeletion(rowID: .existing(1), originalRow: ["2", "inactive"]) + manager.recordRowDeletion(rowID: .existing(3), originalRow: ["4", "inactive"]) + filterToInactive(coordinator) + + coordinator.undoDeleteRow(at: 1) + + #expect(!manager.isRowDeleted(.existing(3))) + #expect(manager.isRowDeleted(.existing(1))) + } +} diff --git a/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift b/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift index 8d4ba364c7..b3f45cacd6 100644 --- a/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift +++ b/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift @@ -158,8 +158,8 @@ struct TableViewCoordinatorValueFilterTests { forColumn: 0 ) - coordinator.tableRowsMutator { rows in - _ = rows.appendInsertedRow(values: [.text("inactive"), .text("z")]) + _ = coordinator.tableRowsMutator { rows in + rows.appendInsertedRow(values: [.text("inactive"), .text("z")]) } coordinator.recomputeValueFilteredIDs() coordinator.updateCache() @@ -177,8 +177,9 @@ struct TableViewCoordinatorValueFilterTests { forColumn: 0 ) - coordinator.tableRowsMutator { rows in + _ = coordinator.tableRowsMutator { rows in rows.columns = ["other", "name"] + return .columnsReplaced } coordinator.applyFullReplace() coordinator.recomputeValueFilteredIDs() diff --git a/TableProTests/Views/Results/ValueFilterChangeGuardTests.swift b/TableProTests/Views/Results/ValueFilterChangeGuardTests.swift index 835ee70ba3..62defb8771 100644 --- a/TableProTests/Views/Results/ValueFilterChangeGuardTests.swift +++ b/TableProTests/Views/Results/ValueFilterChangeGuardTests.swift @@ -2,9 +2,8 @@ // ValueFilterChangeGuardTests.swift // TableProTests // -// A pending cell edit is recorded against a display row, so anything that changes which row a -// position names re-points it. Sort, pagination and the WHERE filter already confirm before doing -// that; the per-column value filter did not. (#2667) +// Sort, pagination and the WHERE filter confirm before changing the display order while edits +// are pending, and the per-column value filter does the same. (#2667) // import AppKit diff --git a/TableProTests/Views/SQLReviewSheetTests.swift b/TableProTests/Views/SQLReviewSheetTests.swift index 63b881ad7d..0c37089818 100644 --- a/TableProTests/Views/SQLReviewSheetTests.swift +++ b/TableProTests/Views/SQLReviewSheetTests.swift @@ -78,6 +78,79 @@ struct SQLReviewSheetTests { #expect(result.display.contains("501 more characters")) } + /// A preview may make MQL easier to read. A confirmation may not: the user is agreeing to the + /// text in front of them, so it has to be the text that runs. + @Test("Verbatim mode leaves MongoDB Extended JSON and the terminator alone") + func verbatimModeDoesNotRewrite() { + let statement = #"db.users.deleteOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}})"# + let result = SQLReviewSheet.build( + statements: [statement], + databaseType: .mongodb, + verbatim: true + ) + #expect(result.full == statement) + #expect(result.display == statement) + #expect(!result.full.contains("ObjectId(")) + #expect(!result.full.hasSuffix(";")) + } + + /// A preview may stop early and leave the rest to Copy All. A confirmation may not: a `WHERE` + /// clause past the cut is exactly the part the user needed to read. + @Test("A statement past the display cap is still shown whole when it is being confirmed") + func verbatimModeNeverTruncates() { + let padding = String(repeating: "a", count: SQLReviewSheet.maxDisplayChars + 5_000) + let statement = "UPDATE accounts SET note = '\(padding)' WHERE customer_id = 42" + let result = SQLReviewSheet.build(statements: [statement], databaseType: .mysql, verbatim: true) + + #expect(result.display == result.full) + #expect(result.full == statement) + #expect(result.mode != .truncated) + #expect(result.display.hasSuffix("WHERE customer_id = 42")) + } + + @Test("A preview past the display cap still truncates and says so") + func previewModeStillTruncates() { + let body = String(repeating: "a", count: SQLReviewSheet.maxDisplayChars + 500) + let result = SQLReviewSheet.build(statements: [body], databaseType: .mysql) + #expect(result.mode == .truncated) + #expect(result.display != result.full) + } + + /// The windowless path holds the main actor inside `NSApp.runModal` until the button resolves + /// its gate, so a confirmation that deferred the answer to a task would deadlock. + @Test("A confirmation answers on the button, not on a task") + func confirmationWorkIsImmediate() { + var answered = false + let action = SQLReviewSheet.PrimaryAction( + title: "Execute", + isDestructive: false, + takesDefaultAction: false, + work: .immediate { answered = true } + ) + guard case .immediate(let perform) = action.work else { + Issue.record("a confirmation must answer immediately") + return + } + perform() + #expect(answered) + } + + @Test("Applying a plan keeps the asynchronous form") + func applyWorkStaysAsynchronous() { + let action = SQLReviewSheet.PrimaryAction(title: "Execute", isDestructive: false) {} + guard case .asynchronous = action.work else { + Issue.record("applying a plan runs for as long as the server takes") + return + } + } + + @Test("A preview still rewrites Extended JSON and terminates the statement") + func previewModeStillRewrites() { + let statement = #"db.users.deleteOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}})"# + let result = SQLReviewSheet.build(statements: [statement], databaseType: .mysql) + #expect(result.full.hasSuffix(";")) + } + @Test("Empty statement list returns empty display") func emptyStatements() { let result = SQLReviewSheet.build(statements: [], databaseType: .mysql) diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 8b09fe1240..ca07ad29e9 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -34,7 +34,8 @@ struct DatabaseTreeMenuSpecTests { supportsRename: Bool = true, canCopyObjects: Bool = true, canDuplicateDatabase: Bool = true, - canCreateType: Bool = false + canCreateType: Bool = false, + objectToolSupport: DatabaseObjectToolEligibility.Support = .none ) -> DatabaseTreeMenuContext { DatabaseTreeMenuContext( clicked: clicked, @@ -78,7 +79,17 @@ struct DatabaseTreeMenuSpecTests { hasDatabaseFilter: hasDatabaseFilter, canCopyObjects: canCopyObjects, canDuplicateDatabase: canDuplicateDatabase, - canCreateType: canCreateType + canCreateType: canCreateType, + objectToolSupport: objectToolSupport + ) + } + + /// What the PostgreSQL driver answers: every table-like kind can be commented on, and a + /// materialized view can be refreshed. + private var postgresSupport: DatabaseObjectToolEligibility.Support { + DatabaseObjectToolEligibility.Support( + canRefreshMaterializedViews: true, + commentableTypes: [.table, .partitionedTable, .view, .materializedView, .foreignTable] ) } @@ -380,6 +391,89 @@ struct DatabaseTreeMenuSpecTests { .contains(.editViewDefinition(table))) } + @Test("Show DDL and Copy DDL are offered for a view and a materialized view, and not for a table") + func showAndCopyDDLAreViewOnly() { + let view = tableRef("active_users", type: .view) + let matview = tableRef("sales_totals", type: .materializedView) + let table = tableRef("users") + + for ref in [view, matview] { + let issued = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .table(ref)))) + #expect(issued.contains(.copyDDL(ref))) + #expect(issued.contains { command in + guard case .showObjectSource(let objectRef) = command else { return false } + return objectRef.name == ref.table.name && objectRef.schema == "public" && objectRef.database == "app" + }) + } + + let tableCommands = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .table(table)))) + #expect(!tableCommands.contains(.copyDDL(table))) + #expect(!tableCommands.contains { command in + if case .showObjectSource = command { return true } + return false + }) + } + + /// Reading a definition writes nothing, so a read-only connection still offers it. Editing the + /// definition is the command that disappears. + @Test("Read-only keeps Show DDL and Copy DDL") + func readOnlyKeepsDDLCommands() { + let view = tableRef("active_users", type: .view) + let issued = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .table(view), isReadOnly: true))) + + #expect(issued.contains(.copyDDL(view))) + #expect(!issued.contains(.editViewDefinition(view))) + } + + @Test("Only a materialized view offers Refresh Materialized View") + func refreshIsMaterializedViewOnly() { + let matview = tableRef("sales_totals", type: .materializedView) + let view = tableRef("active_users", type: .view) + let table = tableRef("users") + + #expect(commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .table(matview), objectToolSupport: postgresSupport) + )).contains(.refreshMaterializedView(matview))) + + for ref in [view, table] { + #expect(!commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .table(ref), objectToolSupport: postgresSupport) + )).contains(.refreshMaterializedView(ref))) + } + } + + @Test("Refresh is absent without a driver statement for it, and when read-only") + func refreshNeedsDriverSupportAndWriteAccess() { + let matview = tableRef("sales_totals", type: .materializedView) + + #expect(!commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .table(matview)))) + .contains(.refreshMaterializedView(matview))) + #expect(!commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .table(matview), isReadOnly: true, objectToolSupport: postgresSupport) + )).contains(.refreshMaterializedView(matview))) + } + + @Test("Edit Comment follows the kinds the driver can comment on") + func editCommentFollowsDriverSupport() { + let table = tableRef("users") + let matview = tableRef("sales_totals", type: .materializedView) + let external = tableRef("events", type: .externalTable) + + for ref in [table, matview] { + #expect(commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .table(ref), objectToolSupport: postgresSupport) + )).contains(.editComment(ref))) + } + #expect(!commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .table(external), objectToolSupport: postgresSupport) + )).contains(.editComment(external))) + #expect(!commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .table(table)))) + .contains(.editComment(table))) + #expect(!commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .table(table), isReadOnly: true, objectToolSupport: postgresSupport) + )).contains(.editComment(table))) + } + // MARK: - Containers @Test("Use as Active is omitted for the container already in use") diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index 627a770ae4..93bb0c96f5 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -124,12 +124,16 @@ struct SidebarContextMenuLogicTests { // MARK: - Maintenance group disabled rule - @Test("Maintenance group enabled with selection, writable, and supported ops") + private func operation(_ name: String) -> PluginMaintenanceOperation { + PluginMaintenanceOperation(name: name, appliesTo: [.table], scope: .object) + } + + @Test("Maintenance group enabled with selection, writable, and applicable ops") func maintenanceEnabledAllConditions() { #expect(SidebarContextMenuLogic.maintenanceGroupEnabled( isReadOnly: false, hasSelection: true, - supportedOperations: ["ANALYZE", "OPTIMIZE"] + applicableOperations: [operation("ANALYZE"), operation("OPTIMIZE")] )) } @@ -138,7 +142,7 @@ struct SidebarContextMenuLogicTests { #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( isReadOnly: true, hasSelection: true, - supportedOperations: ["ANALYZE"] + applicableOperations: [operation("ANALYZE")] )) } @@ -147,16 +151,16 @@ struct SidebarContextMenuLogicTests { #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( isReadOnly: false, hasSelection: false, - supportedOperations: ["ANALYZE"] + applicableOperations: [operation("ANALYZE")] )) } - @Test("Maintenance group disabled when driver exposes no ops") + @Test("Maintenance group disabled when nothing the driver offers applies to the clicked object") func maintenanceDisabledNoOps() { #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( isReadOnly: false, hasSelection: true, - supportedOperations: [] + applicableOperations: [] )) } diff --git a/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift b/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift index f530ce4ade..b67df3b14f 100644 --- a/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift +++ b/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift @@ -20,18 +20,20 @@ struct StructureColumnFieldRegistrationTests { let mysql = PluginManager.shared.structureColumnFields(for: .mysql) let mariadb = PluginManager.shared.structureColumnFields(for: .mariadb) let tidb = PluginManager.shared.structureColumnFields(for: .tidb) + let oceanbase = PluginManager.shared.structureColumnFields(for: .oceanbase) #expect(Set(mysql) == Set(mariadb)) #expect(Set(mysql) == Set(tidb)) + #expect(Set(mysql) == Set(oceanbase)) } - @Test("MySQL-protocol engines with on update offer it", arguments: [DatabaseType.mysql, .mariadb, .tidb]) + @Test("MySQL-protocol engines with on update offer it", arguments: [DatabaseType.mysql, .mariadb, .tidb, .oceanbase]) func onUpdateIsOffered(databaseType: DatabaseType) { #expect(PluginManager.shared.structureColumnFields(for: databaseType).contains(.onUpdate)) } @Test("On update is ordered next to the default it complements") func onUpdateFollowsDefaultValue() { - let fields = StructureRowProvider.orderedFields(for: .mysql) + let fields = StructureRowProvider.orderedFields(for: .mysql, serverSupport: .unrestricted) guard let defaultIndex = fields.firstIndex(of: .defaultValue), let onUpdateIndex = fields.firstIndex(of: .onUpdate) else { Issue.record("MySQL is missing the default or on update field") @@ -79,7 +81,7 @@ struct StructureColumnFieldRegistrationTests { arguments: [DatabaseType.postgresql, .mysql, .sqlite, .cockroachdb, .pglite] ) func declaredBooleanFieldsResolve(databaseType: DatabaseType) { - let ordered = StructureRowProvider.orderedFields(for: databaseType) + let ordered = StructureRowProvider.orderedFields(for: databaseType, serverSupport: .unrestricted) let declared = Set(PluginManager.shared.structureColumnFields(for: databaseType)) for field in [StructureColumnField.nullable, .autoIncrement, .onUpdate] where declared.contains(field) { #expect(ordered.contains(field), "\(databaseType.rawValue) declares \(field) but cannot order it") diff --git a/TableProTests/Views/Structure/StructureEditGateTests.swift b/TableProTests/Views/Structure/StructureEditGateTests.swift new file mode 100644 index 0000000000..e4f9430d67 --- /dev/null +++ b/TableProTests/Views/Structure/StructureEditGateTests.swift @@ -0,0 +1,128 @@ +// +// StructureEditGateTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +/// The gate is the one impure half of the decision: it reads the engine's curated matrix and its +/// capability flags, and every call site in the Structure tab asks it rather than reading a flag of +/// its own. They used to read them separately, which is how the footer, the Edit menu's Add Row, the +/// row context menu and the grid's own paste path each got to a different answer. (#2726) +@Suite("Structure Edit Gate") +@MainActor +struct StructureEditGateTests { + private func gate(_ kind: TableInfo.TableType, _ type: DatabaseType = .postgresql) -> StructureEditGate { + StructureEditGate(databaseType: type, objectKind: kind) + } + + @Test("A PostgreSQL table takes every edit end to end") + func tableTakesEverything() { + let table = gate(.table) + #expect(table.allowsAnyEdit) + for operation in StructureEditOperation.allCases { + #expect(table.allows(operation), "table refused \(operation)") + } + } + + @Test("A PostgreSQL view refuses a column and an index but keeps a rename and a default") + func viewSplitsByOperation() { + let view = gate(.view) + #expect(view.allowsAnyEdit) + #expect(view.allows(.renameColumn)) + #expect(view.allows(.setDefault)) + #expect(view.allows(.commentOnColumn)) + #expect(!view.allows(.addColumn)) + #expect(!view.allows(.setNotNull)) + #expect(!view.allows(.addIndex)) + #expect(!view.allows(.addForeignKey)) + #expect(!view.allows(.reorderColumns)) + } + + @Test("A PostgreSQL materialized view takes an index and refuses a default") + func materializedViewTakesAnIndex() { + let matview = gate(.materializedView) + #expect(matview.allows(.addIndex)) + #expect(matview.allows(.dropIndex)) + #expect(!matview.allows(.setDefault)) + #expect(!matview.allows(.addForeignKey)) + } + + @Test("A system table allows nothing at all, so the grid has nothing to offer") + func systemTableAllowsNothing() { + let system = gate(.systemTable) + #expect(!system.allowsAnyEdit) + #expect(system.editableColumnFields.isEmpty) + } + + @Test("A view keeps exactly Name, Default and Comment unlocked") + func viewEditableFields() { + #expect(gate(.view).editableColumnFields == [.name, .defaultValue, .comment]) + } + + /// An engine nobody has curated gets tables only, so a MySQL view's grid is read-only rather than + /// accepting edits MySQL refuses on a view. + @Test("An uncurated engine withholds every edit on a view") + func uncuratedEngineWithholdsOnViews() { + let view = gate(.view, .mysql) + #expect(!view.allowsAnyEdit) + #expect(view.editableColumnFields.isEmpty) + #expect(!view.allows(.renameColumn)) + #expect(gate(.table, .mysql).allowsAnyEdit) + } + + /// The gate answers the foreign key arm through `ForeignKeyEditPolicy`, so the engine that has to + /// recreate the table still offers the edit and the refusal keeps that policy's own wording + /// rather than the sentence every other constraint shares. + @Test("An engine that recreates the table to change a key still offers the edit") + func rebuildEngineOffersForeignKeys() { + #expect(gate(.table, .sqlite).allows(.addForeignKey)) + #expect(gate(.table, .sqlite).allows(.dropForeignKey)) + #expect(gate(.table).allows(.addForeignKey)) + } + + @Test("A refusing kind reaches the foreign key arm with the kind's own reason") + func foreignKeyRefusalNamesTheKind() { + let matview = gate(.materializedView) + #expect(!matview.allows(.addForeignKey)) + #expect(matview.resolve(.addForeignKey).unavailableReason?.contains("materialized view") == true) + #expect(matview.foreignKeyAvailability.unavailableReason?.contains("materialized view") == true) + } + + @Test("A refused operation carries a reason and an accepted one does not") + func reasonsTravelWithTheRefusal() { + #expect(gate(.view).kindRefusal(.addIndex)?.isEmpty == false) + #expect(gate(.view).kindRefusal(.renameColumn) == nil) + #expect(gate(.materializedView).kindRefusal(.addIndex) == nil) + #expect(gate(.materializedView).resolve(.setDefault).unavailableReason?.isEmpty == false) + } + + /// The per-column lock is name-keyed, and the only names it can use are the Columns grid's own + /// headings. If a provider ever respelled one, the field behind it would silently unlock, so the + /// two lists are pinned to each other here. + @Test("The Columns grid's headings are exactly the field display names the lock uses") + func headingsMatchTheFieldNames() { + let provider = StructureRowProvider( + changeManager: StructureChangeManager(), + tab: .columns, + databaseType: .postgresql, + additionalFields: [.primaryKey], + serverSupport: .unrestricted + ) + #expect(provider.columns == provider.orderedColumnFields.map(\.displayName)) + + let locked = Set( + StructureColumnField.allCases + .filter { !gate(.view).editableColumnFields.contains($0) } + .map(\.displayName) + ) + let unlocked = provider.columns.filter { !locked.contains($0) } + #expect(unlocked.contains(StructureColumnField.name.displayName)) + #expect(unlocked.contains(StructureColumnField.defaultValue.displayName)) + #expect(!unlocked.contains(StructureColumnField.type.displayName)) + #expect(!unlocked.contains(StructureColumnField.nullable.displayName)) + } +} diff --git a/TableProTests/Views/Structure/StructureFooterPolicyTests.swift b/TableProTests/Views/Structure/StructureFooterPolicyTests.swift new file mode 100644 index 0000000000..0ab86e7711 --- /dev/null +++ b/TableProTests/Views/Structure/StructureFooterPolicyTests.swift @@ -0,0 +1,140 @@ +// +// StructureFooterPolicyTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +/// The "+" and "-" under a structure list used to take their label, their enabled state and their +/// tooltip from three different switches, and only the Foreign Keys one asked about the object at +/// all. So a view offered an enabled "Add Column" over a statement PostgreSQL always refuses, with +/// nothing to explain it. (#2726) +@Suite("Structure Footer Policy") +struct StructureFooterPolicyTests { + private func resolve( + tab: StructureTab, + kind: TableInfo.TableType, + matrix: StructureObjectEditMatrix = .postgreSQL, + canEditSchema: Bool = true, + hasSelection: Bool = true + ) -> StructureFooterCapability { + StructureFooterPolicy.resolve( + tab: tab, + canEditSchema: canEditSchema, + hasSelection: hasSelection, + resolve: { operation in + StructureEditEligibility.resolve( + operation, + on: kind, + matrix: matrix, + engineAllows: true, + engineName: "PostgreSQL", + canEditSchema: canEditSchema + ) + } + ) + } + + @Test("A table offers both buttons on all four editable tabs") + func tableOffersEverything() { + for tab in [StructureTab.columns, .indexes, .foreignKeys, .checkConstraints] { + let capability = resolve(tab: tab, kind: .table) + #expect(capability.canAdd, "\(tab.rawValue)") + #expect(capability.canRemove, "\(tab.rawValue)") + #expect(capability.unavailableReason == nil, "\(tab.rawValue)") + } + } + + /// Shown and dimmed, not hidden. The pair disappearing would read as "this tab has no columns", + /// and the tooltip is the only place the refusal can be stated. + @Test("A view keeps the Columns pair on screen, dimmed, with a reason") + func viewDimsTheColumnsPair() { + let capability = resolve(tab: .columns, kind: .view) + #expect(!capability.canAdd) + #expect(!capability.canRemove) + #expect(capability.isActive) + #expect(capability.addLabel.isEmpty == false) + #expect(capability.unavailableReason?.isEmpty == false) + } + + @Test("A materialized view offers an index and refuses a column") + func materializedViewSplitsByTab() { + let indexes = resolve(tab: .indexes, kind: .materializedView) + #expect(indexes.canAdd) + #expect(indexes.canRemove) + #expect(indexes.unavailableReason == nil) + + let columns = resolve(tab: .columns, kind: .materializedView) + #expect(!columns.canAdd) + #expect(columns.unavailableReason?.isEmpty == false) + } + + @Test("A foreign table offers a check constraint and refuses an index") + func foreignTableSplitsByTab() { + let checks = resolve(tab: .checkConstraints, kind: .foreignTable) + #expect(checks.canAdd) + + let indexes = resolve(tab: .indexes, kind: .foreignTable) + #expect(!indexes.canAdd) + #expect(indexes.unavailableReason?.isEmpty == false) + } + + @Test("A foreign table refuses a foreign key, which is the constraint PostgreSQL rejects on one") + func foreignTableRefusesForeignKeys() { + let keys = resolve(tab: .foreignKeys, kind: .foreignTable) + #expect(!keys.canAdd) + #expect(keys.unavailableReason?.isEmpty == false) + } + + /// Today's behaviour, preserved on purpose: an engine that cannot edit structure at all hides the + /// pair rather than showing two dead buttons on every tab. + @Test("An engine that cannot edit structure hides the pair entirely") + func readOnlyEngineHidesThePair() { + let capability = resolve(tab: .columns, kind: .table, canEditSchema: false) + #expect(!capability.isActive) + #expect(capability.addLabel.isEmpty) + #expect(!capability.canAdd) + #expect(!capability.canRemove) + } + + @Test("Nothing selected dims Remove without dimming Add and without inventing a reason") + func emptySelectionOnlyDimsRemove() { + let capability = resolve(tab: .columns, kind: .table, hasSelection: false) + #expect(capability.canAdd) + #expect(!capability.canRemove) + #expect(capability.unavailableReason == nil) + } + + @Test("The tabs with nothing to add have no operation and no pair") + func nonEditableTabsHaveNoPair() { + for tab in [StructureTab.ddl, .parts, .triggers] { + #expect(StructureFooterPolicy.operation(forAdding: tab) == nil, "\(tab.rawValue)") + #expect(StructureFooterPolicy.operation(forRemoving: tab) == nil, "\(tab.rawValue)") + #expect(!resolve(tab: tab, kind: .table).isActive, "\(tab.rawValue)") + } + } + + @Test("Each editable tab names the add and the drop of its own object") + func operationsMatchTheirTabs() { + #expect(StructureFooterPolicy.operation(forAdding: .columns) == .addColumn) + #expect(StructureFooterPolicy.operation(forRemoving: .columns) == .dropColumn) + #expect(StructureFooterPolicy.operation(forAdding: .indexes) == .addIndex) + #expect(StructureFooterPolicy.operation(forRemoving: .indexes) == .dropIndex) + #expect(StructureFooterPolicy.operation(forAdding: .foreignKeys) == .addForeignKey) + #expect(StructureFooterPolicy.operation(forRemoving: .foreignKeys) == .dropForeignKey) + #expect(StructureFooterPolicy.operation(forAdding: .checkConstraints) == .addCheckConstraint) + #expect(StructureFooterPolicy.operation(forRemoving: .checkConstraints) == .dropCheckConstraint) + } + + /// An engine nobody has measured falls back to tables only, so every non-table dims rather than + /// offering an edit its server may refuse. + @Test("An uncurated engine dims the pair on a view") + func uncuratedEngineDimsNonTables() { + let capability = resolve(tab: .columns, kind: .view, matrix: .tablesOnly) + #expect(!capability.canAdd) + #expect(capability.isActive) + #expect(capability.unavailableReason?.isEmpty == false) + } +} diff --git a/TableProTests/Views/Structure/StructureGeneratedColumnFieldTests.swift b/TableProTests/Views/Structure/StructureGeneratedColumnFieldTests.swift index a0d9853bfa..ba1bd02b4c 100644 --- a/TableProTests/Views/Structure/StructureGeneratedColumnFieldTests.swift +++ b/TableProTests/Views/Structure/StructureGeneratedColumnFieldTests.swift @@ -25,7 +25,8 @@ struct StructureGeneratedColumnFieldTests { func canonicalOrderCoversEveryField() { let ordered = StructureRowProvider.orderedFields( for: .mysql, - additionalFields: Set(StructureColumnField.allCases) + additionalFields: Set(StructureColumnField.allCases), + serverSupport: .unrestricted ) let missing = StructureColumnField.allCases.filter { !ordered.contains($0) } #expect(missing.isEmpty, "Fields absent from canonicalFieldOrder are discarded: \(missing)") diff --git a/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift b/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift index 436d568b94..8bc5b07ebd 100644 --- a/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift +++ b/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift @@ -16,7 +16,8 @@ import Testing struct StructureGridDelegateAddRowTests { private func makeDelegate( selectedTab: StructureTab = .columns, - type: DatabaseType = .mysql + type: DatabaseType = .mysql, + objectKind: TableInfo.TableType = .table ) -> (StructureGridDelegate, StructureChangeManager) { let manager = StructureChangeManager() let connection = TestFixtures.makeConnection(type: type) @@ -25,6 +26,7 @@ struct StructureGridDelegateAddRowTests { selectedTab: selectedTab, connection: connection, tableName: "t", + objectKind: objectKind, coordinator: nil ) return (delegate, manager) @@ -179,4 +181,70 @@ struct StructureGridDelegateAddRowTests { #expect(manager.workingIndexes.count == before - 1) } + + // MARK: - Per object kind + + /// The footer pair is not the only way in. Cmd+Shift+N reaches `dataGridAddRow` through + /// `structureActions`, the row and empty-space menus reach it directly, and none of them consults + /// the dimmed button, so the delegate has to refuse for itself. (#2726) + @Test("A PostgreSQL view refuses an added column, whatever reaches the delegate") + func viewRefusesAnAddedColumn() { + let (delegate, manager) = makeDelegate(selectedTab: .columns, type: .postgresql, objectKind: .view) + delegate.dataGridAddRow() + #expect(manager.workingColumns.isEmpty) + } + + @Test("A PostgreSQL view refuses an added index and a materialized view accepts one") + func indexesFollowTheObjectKind() { + let (view, viewManager) = makeDelegate(selectedTab: .indexes, type: .postgresql, objectKind: .view) + view.dataGridAddRow() + #expect(viewManager.workingIndexes.isEmpty) + + let (matview, matviewManager) = makeDelegate( + selectedTab: .indexes, type: .postgresql, objectKind: .materializedView + ) + matview.dataGridAddRow() + #expect(matviewManager.workingIndexes.count == 1) + } + + @Test("A PostgreSQL materialized view refuses a foreign key") + func materializedViewRefusesAForeignKey() { + let (delegate, manager) = makeDelegate( + selectedTab: .foreignKeys, type: .postgresql, objectKind: .materializedView + ) + delegate.dataGridAddRow() + #expect(manager.workingForeignKeys.isEmpty) + } + + @Test("A view refuses to drop a column its own grid is showing") + func viewRefusesADroppedColumn() { + let manager = StructureChangeManager() + manager.addColumn(.placeholder()) + let before = manager.workingColumns.count + #expect(before == 1) + + let view = StructureGridDelegate( + structureChangeManager: manager, + selectedTab: .columns, + connection: TestFixtures.makeConnection(type: .postgresql), + tableName: "v_sales", + objectKind: .view, + coordinator: nil + ) + view.dataGridDeleteRows([0]) + + #expect(manager.workingColumns.count == before) + } + + @Test("A system table refuses every add") + func systemTableRefusesEveryAdd() { + for tab in [StructureTab.columns, .indexes, .foreignKeys, .checkConstraints] { + let (delegate, manager) = makeDelegate(selectedTab: tab, type: .postgresql, objectKind: .systemTable) + delegate.dataGridAddRow() + #expect(manager.workingColumns.isEmpty, "\(tab.rawValue)") + #expect(manager.workingIndexes.isEmpty, "\(tab.rawValue)") + #expect(manager.workingForeignKeys.isEmpty, "\(tab.rawValue)") + #expect(manager.workingCheckConstraints.isEmpty, "\(tab.rawValue)") + } + } } diff --git a/TableProTests/Views/Structure/StructureGridDelegateInspectorTests.swift b/TableProTests/Views/Structure/StructureGridDelegateInspectorTests.swift index 74b1218cd4..db5a696fed 100644 --- a/TableProTests/Views/Structure/StructureGridDelegateInspectorTests.swift +++ b/TableProTests/Views/Structure/StructureGridDelegateInspectorTests.swift @@ -54,6 +54,7 @@ struct StructureGridDelegateInspectorTests { tab: .columns, databaseType: .mysql, additionalFields: [.primaryKey], + serverSupport: .unrestricted, filterText: filterText ) delegate.currentProvider = provider @@ -144,7 +145,8 @@ struct StructureGridDelegateInspectorTests { changeManager: manager, tab: .columns, databaseType: .mysql, - additionalFields: [.primaryKey] + additionalFields: [.primaryKey], + serverSupport: .unrestricted ) delegate.orderedFields = provider.orderedColumnFields diff --git a/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift b/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift index c6bae88932..b96f4f9753 100644 --- a/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift +++ b/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift @@ -38,6 +38,7 @@ struct StructureInspectorRowBuilderTests { tab: tab, databaseType: .mysql, additionalFields: [.primaryKey], + serverSupport: .unrestricted, filterText: filterText, sortDescriptor: sortDescriptor ) diff --git a/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift b/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift index c6afd019ad..9d99647fcb 100644 --- a/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift +++ b/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift @@ -42,6 +42,7 @@ struct StructureRowProviderBooleanOptionsTests { tab: tab, databaseType: .postgresql, additionalFields: [.primaryKey, .onUpdate], + serverSupport: .unrestricted, filterText: nil, sortDescriptor: nil ) diff --git a/TableProTests/Views/Structure/StructureRowProviderTests.swift b/TableProTests/Views/Structure/StructureRowProviderTests.swift index 894566ed3d..fd59219cda 100644 --- a/TableProTests/Views/Structure/StructureRowProviderTests.swift +++ b/TableProTests/Views/Structure/StructureRowProviderTests.swift @@ -44,6 +44,7 @@ struct StructureRowProviderTests { tab: .columns, databaseType: .mysql, additionalFields: [.name, .type], + serverSupport: .unrestricted, filterText: filterText, sortDescriptor: sortDescriptor ) @@ -148,6 +149,7 @@ struct StructureRowProviderChangeStateTests { tab: tab, databaseType: .mysql, additionalFields: [.primaryKey], + serverSupport: .unrestricted, filterText: filterText ) } diff --git a/TableProTests/Views/Structure/StructureServerSupportTests.swift b/TableProTests/Views/Structure/StructureServerSupportTests.swift new file mode 100644 index 0000000000..b9226b1925 --- /dev/null +++ b/TableProTests/Views/Structure/StructureServerSupportTests.swift @@ -0,0 +1,177 @@ +// +// StructureServerSupportTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class StructureSupportStubDriver: PluginDatabaseDriver, @unchecked Sendable { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + var hiddenFields: Set = [] + var hiddenIndexTypes: Set = [] + + var unsupportedStructureColumnFields: Set { hiddenFields } + var unsupportedIndexTypes: Set { hiddenIndexTypes } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +private final class DefaultStructureStubDriver: PluginDatabaseDriver, @unchecked Sendable { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@MainActor @Suite("Structure server support") +struct StructureServerSupportTests { + private static let additionalFields: Set = [ + .primaryKey, .generated, .generationExpression + ] + + private static let legacyServer = StructureServerSupport( + unsupportedColumnFields: [.generated, .generationExpression], + unsupportedIndexTypes: ["brin"] + ) + + private func makeManager() -> StructureChangeManager { + let manager = StructureChangeManager() + manager.workingColumns = [EditableColumnDefinition.placeholder()] + return manager + } + + private func provider(tab: StructureTab, support: StructureServerSupport) -> StructureRowProvider { + StructureRowProvider( + changeManager: makeManager(), + tab: tab, + databaseType: .postgresql, + additionalFields: Self.additionalFields, + serverSupport: support + ) + } + + @Test("An unrestricted server offers every field and index type") + func unrestrictedOffersEverything() { + let support = StructureServerSupport.unrestricted + #expect(StructureColumnField.allCases.allSatisfy { support.offers($0) }) + #expect(support.offeredIndexTypes(from: EditableIndexDefinition.IndexType.allCases) + == EditableIndexDefinition.IndexType.allCases) + } + + @Test("Index types are matched case-insensitively") + func indexTypesMatchCaseInsensitively() { + let offered = Self.legacyServer.offeredIndexTypes(from: EditableIndexDefinition.IndexType.allCases) + #expect(!offered.contains(.brin)) + #expect(offered.contains(.gin)) + #expect(offered.count == EditableIndexDefinition.IndexType.allCases.count - 1) + } + + @Test("A server without generated columns never shows the Generated and Expression columns") + func legacyServerHidesGeneratedFields() { + let fields = provider(tab: .columns, support: Self.legacyServer).orderedColumnFields + #expect(!fields.contains(.generated)) + #expect(!fields.contains(.generationExpression)) + #expect(fields.contains(.primaryKey)) + } + + @Test("A server with generated columns keeps them") + func modernServerKeepsGeneratedFields() { + let fields = provider(tab: .columns, support: .unrestricted).orderedColumnFields + #expect(fields.contains(.generated)) + #expect(fields.contains(.generationExpression)) + } + + @Test("The static field order applies the same server support as the provider") + func staticOrderMatchesProvider() { + let fields = StructureRowProvider.orderedFields( + for: .postgresql, + additionalFields: Self.additionalFields, + serverSupport: Self.legacyServer + ) + #expect(fields == provider(tab: .columns, support: Self.legacyServer).orderedColumnFields) + #expect(!fields.contains(.generated)) + } + + @Test("The index type dropdown leaves out what the server lacks") + func indexTypeDropdownFiltersUnsupportedTypes() { + let types = provider(tab: .indexes, support: Self.legacyServer).customDropdownOptions[2]?.compactMap(\.sql) ?? [] + #expect(!types.isEmpty) + #expect(!types.contains("BRIN")) + #expect(types.contains("GIN")) + let unrestricted = provider(tab: .indexes, support: .unrestricted).customDropdownOptions[2]?.compactMap(\.sql) + #expect(unrestricted?.contains("BRIN") == true) + } + + @Test("The adapter bridges what the connected server cannot honour") + func adapterBridgesServerSupport() { + let driver = StructureSupportStubDriver() + driver.hiddenFields = [.generated, .generationExpression] + driver.hiddenIndexTypes = ["BRIN"] + let adapter = PluginDriverAdapter( + connection: DatabaseConnection(name: "Test", type: .postgresql), + pluginDriver: driver + ) + let support = StructureServerSupport(driver: adapter) + #expect(support.unsupportedColumnFields == [.generated, .generationExpression]) + #expect(support.unsupportedIndexTypes == ["BRIN"]) + } + + @Test("A driver built before the hook existed restricts nothing") + func defaultDriverRestrictsNothing() { + let adapter = PluginDriverAdapter( + connection: DatabaseConnection(name: "Test", type: .postgresql), + pluginDriver: DefaultStructureStubDriver() + ) + #expect(StructureServerSupport(driver: adapter) == .unrestricted) + #expect(StructureServerSupport(driver: nil) == .unrestricted) + } +} diff --git a/TableProUITests/HighlightRulesUITests.swift b/TableProUITests/HighlightRulesUITests.swift new file mode 100644 index 0000000000..5cb6e155c4 --- /dev/null +++ b/TableProUITests/HighlightRulesUITests.swift @@ -0,0 +1,106 @@ +// +// HighlightRulesUITests.swift +// TableProUITests +// + +import AppKit +import XCTest + +final class HighlightRulesUITests: UITestCase { + func testAddingARuleFromTheStatusBarKeepsItForTheTable() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + _ = try albumGrid(in: window) + + openRulesFromStatusBar(in: window) + let add = window.buttons["highlight-rules-add"].firstMatch + XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The popover must offer Add Rule") + XCTAssertFalse(ruleCheckbox(in: window).exists, "A table nobody highlighted starts with no rules") + add.click() + XCTAssertTrue(ruleCheckbox(in: window).waitToExist(timeout: 10), "Add Rule must list a new rule") + app.typeText("1") + + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(add.waitForNonExistence(timeout: 5), "Escape in the value field must close the popover") + + openRulesFromStatusBar(in: window) + XCTAssertTrue( + ruleCheckbox(in: window).waitToExist(timeout: 10), + "A rule belongs to the table, so reopening the popover lists it again" + ) + + let reopenedAdd = window.buttons["highlight-rules-add"].firstMatch + XCTAssertTrue(waitUntilHittable(reopenedAdd, timeout: 10)) + reopenedAdd.click() + XCTAssertTrue(waitForPredicate(timeout: 10) { self.ruleCheckboxes(in: window).count == 2 }) + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(reopenedAdd.waitForNonExistence(timeout: 5)) + + openRulesFromStatusBar(in: window) + XCTAssertTrue(ruleCheckbox(in: window).waitToExist(timeout: 10)) + XCTAssertEqual(ruleCheckboxes(in: window).count, 1, "A rule closed without a value is not kept") + } + + func testTheCellMenuOffersHighlightAndOpensTheRules() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try albumGrid(in: window) + + let cell = gridPoint(in: grid, of: window, dy: 70) + cell.click() + Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) + cell.rightClick() + + let highlight = window.menus.menuItems["Highlight"].firstMatch + XCTAssertTrue(highlight.waitToExist(timeout: 15), "A cell's context menu must offer Highlight") + highlight.hover() + + let showRules = contextMenuItem("Highlight Rules…", in: app) + XCTAssertTrue(waitUntilHittable(showRules, timeout: 10), "The Highlight submenu must offer Highlight Rules…") + showRules.click() + + XCTAssertTrue( + window.buttons["highlight-rules-add"].firstMatch.waitToExist(timeout: 10), + "Highlight Rules… must open the rules popover" + ) + } + + // MARK: - Helpers + + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + return window + } + + private func albumGrid(in window: XCUIElement) throws -> XCUIElement { + let row = window.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", "Table: Album")) + .firstMatch + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") + clickAtCenter(row) + + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") + XCTAssertTrue(waitForClickableRows(in: grid), "Album must load rows before a cell can be highlighted") + return grid + } + + private func openRulesFromStatusBar(in window: XCUIElement) { + let button = window.buttons["result-status-highlight"] + XCTAssertTrue(waitUntilHittable(button, timeout: 15), "The status bar must offer Highlight") + button.click() + } + + private func ruleCheckbox(in window: XCUIElement) -> XCUIElement { + ruleCheckboxes(in: window).firstMatch + } + + private func ruleCheckboxes(in window: XCUIElement) -> XCUIElementQuery { + window.checkBoxes.matching(identifier: "highlight-rule-enabled") + } +} diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index fb9f4db6a8..9781c6b5ff 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -182,6 +182,23 @@ internal class UITestCase: XCTestCase { waitForPredicate(timeout: timeout) { element.exists && element.isHittable } } + /// The settings window is 720x500, and the Editor pane is taller than that once the font + /// pickers sit above the SQL toggles. XCUITest reports the Vim switch as existing and not + /// hittable, because it is below the fold; a swipe is what brings it into the window. + internal func waitUntilHittableByScrolling( + _ element: XCUIElement, + in container: XCUIElement, + timeout: TimeInterval + ) -> Bool { + if waitUntilHittable(element, timeout: 1) { return true } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + container.scrollViews.firstMatch.swipeUp() + if waitUntilHittable(element, timeout: 0.8) { return true } + } + return element.exists && element.isHittable + } + /// Switches the result to its Structure editor, through **View > Result View > Structure** /// rather than the `Structure` segment of the results status bar. /// diff --git a/TableProUITests/ValueFilterEditUITests.swift b/TableProUITests/ValueFilterEditUITests.swift new file mode 100644 index 0000000000..70eeabf857 --- /dev/null +++ b/TableProUITests/ValueFilterEditUITests.swift @@ -0,0 +1,155 @@ +// +// ValueFilterEditUITests.swift +// TableProUITests +// + +import AppKit +import XCTest + +final class ValueFilterEditUITests: UITestCase { + private static let table = "Employee" + private static let filteredColumn = "Title" + private static let keptTitle = "IT Staff" + private static let editedColumnPosition = 2 + private static let editedValue = "Edited Under Filter" + + func testDiscardingAnEditMadeUnderAValueFilterRestoresTheEditedRow() throws { + let app = try launchWithSampleDatabase() + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + let grid = openTable(in: window) + + let secondRowBefore = try cellValue(row: 2, in: grid) + let lastRowBefore = try cellValue(row: 8, in: grid) + + rightClickHeader(Self.filteredColumn, in: grid) + let filterValues = contextMenuItem("Filter Values…", in: app) + XCTAssertTrue(filterValues.waitToExist(timeout: 10), "The header menu must offer Filter Values…") + filterValues.click() + keepOnly(Self.keptTitle, in: window) + + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.currentCellValue(row: 2, in: grid) == lastRowBefore }, + "Under the filter the table's last row must be shown second" + ) + + editCell(row: 2, in: grid, app: app, to: Self.editedValue) + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.currentCellValue(row: 2, in: grid) == Self.editedValue }, + "The edit must land on the row shown second" + ) + + rightClickHeader(Self.filteredColumn, in: grid) + let clearFilter = contextMenuItem("Clear Value Filter", in: app) + XCTAssertTrue(clearFilter.waitToExist(timeout: 10), "A filtered column's menu must offer Clear Value Filter") + clearFilter.click() + + guard let discard = discardButton(in: app, of: window) else { + XCTFail("Clearing the filter over a pending edit must ask first") + return + } + discard.click() + + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.currentCellValue(row: 8, in: grid) == lastRowBefore }, + "Discard must put back the value of the row that was edited" + ) + XCTAssertEqual( + currentCellValue(row: 2, in: grid), + secondRowBefore, + "Discard must not write the edited row's value into the row that shared its position" + ) + } + + // MARK: - Helpers + + private func openTable(in window: XCUIElement) -> XCUIElement { + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + let row = objectBrowserRow(Self.table, in: window) + XCTAssertTrue(row.waitToExist(timeout: 30), "The object browser must list \(Self.table)") + row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() + + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(waitForClickableRows(in: grid, timeout: 30), "\(Self.table) must load its rows") + return grid + } + + private func rightClickHeader(_ column: String, in grid: XCUIElement) { + let header = grid.buttons + .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(column)")) + .firstMatch + XCTAssertTrue(header.waitToExist(timeout: 30), "The grid must publish a \(column) header") + point(at: header.frame, in: grid).rightClick() + } + + private func keepOnly(_ value: String, in window: XCUIElement) { + let popover = window.popovers.firstMatch + let search = popover.searchFields["value-filter-search"].firstMatch + XCTAssertTrue(search.waitToExist(timeout: 10), "Filter Values… must open the value filter") + search.typeText(value) + + let selectAll = popover.checkBoxes.matching(NSPredicate(format: "label == %@", "Select All")).firstMatch + XCTAssertTrue(waitUntilHittable(selectAll, timeout: 10), "The value filter must offer Select All") + selectAll.click() + + let valueToggle = popover.checkBoxes.matching(identifier: "value-filter-value").firstMatch + XCTAssertTrue(waitUntilHittable(valueToggle, timeout: 10), "The search must leave \(value) in the list") + valueToggle.click() + + let apply = popover.buttons["Apply"].firstMatch + XCTAssertTrue(waitUntilHittable(apply, timeout: 10), "The value filter must offer Apply") + apply.click() + XCTAssertTrue(popover.waitForNonExistence(timeout: 10), "Apply must close the value filter") + } + + /// Asked of the sheet and then of the app's own windows, never of `app.buttons`: the runner + /// publishes the Touch Bar's copy of a button under the application element, and XCUITest + /// refuses to click that one. + private func discardButton(in app: XCUIApplication, of window: XCUIElement) -> XCUIElement? { + let queries = [ + window.sheets.buttons["Discard"], + app.dialogs.buttons["Discard"], + app.windows.buttons["Discard"] + ] + for query in queries { + let button = query.firstMatch + if waitUntilHittable(button, timeout: 5) { return button } + } + return nil + } + + private func editCell(row: Int, in grid: XCUIElement, app: XCUIApplication, to value: String) { + let cell = cellElement(row: row, in: grid) + XCTAssertTrue(cell.waitToExist(timeout: 10), "Row \(row) must publish its cells") + point(at: cell.frame, in: grid).click() + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + app.typeKey("a", modifierFlags: .command) + app.typeText(value) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + } + + private func point(at frame: CGRect, in grid: XCUIElement) -> XCUICoordinate { + let origin = grid.frame.origin + return grid.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) + } + + private func cellElement(row: Int, in grid: XCUIElement) -> XCUIElement { + grid.staticTexts + .matching(NSPredicate( + format: "label BEGINSWITH %@", + "Row \(row), column \(Self.editedColumnPosition): " + )) + .firstMatch + } + + private func currentCellValue(row: Int, in grid: XCUIElement) -> String? { + let cell = cellElement(row: row, in: grid) + guard cell.exists else { return nil } + return cell.value as? String + } + + private func cellValue(row: Int, in grid: XCUIElement) throws -> String { + _ = waitForPredicate(timeout: 20) { self.currentCellValue(row: row, in: grid) != nil } + return try XCTUnwrap(currentCellValue(row: row, in: grid), "The grid must publish row \(row)'s cells") + } +} diff --git a/TableProUITests/VimNormalModeChordUITests.swift b/TableProUITests/VimNormalModeChordUITests.swift index 542160c710..891843477b 100644 --- a/TableProUITests/VimNormalModeChordUITests.swift +++ b/TableProUITests/VimNormalModeChordUITests.swift @@ -65,7 +65,10 @@ final class VimNormalModeChordUITests: UITestCase { editorPane.click() let vimToggle = settings.descendants(matching: .any).matching(identifier: "vim-mode-toggle").firstMatch - XCTAssertTrue(waitUntilHittable(vimToggle, timeout: 10), "The Editor pane must offer Vim mode") + XCTAssertTrue( + waitUntilHittableByScrolling(vimToggle, in: settings, timeout: 10), + "The Editor pane must offer Vim mode" + ) if isOn(vimToggle) != enabled { vimToggle.click() } diff --git a/docs/connections/connection-form.mdx b/docs/connections/connection-form.mdx index adf4952cea..54e503bf21 100644 --- a/docs/connections/connection-form.mdx +++ b/docs/connections/connection-form.mdx @@ -111,6 +111,7 @@ Metadata connections, the extra ones TablePro opens to read a database's object | [MariaDB](/databases/mariadb) | 3306 | Yes | Yes | Yes | No | Yes | Yes | | [TiDB](/databases/tidb) | 4000 | Yes | Yes | Yes | No | Yes | Yes | | [Databend](/databases/databend) | 3307 | Yes | Yes | Yes | No | Yes | Yes | +| [OceanBase](/databases/oceanbase) | 2881 | Yes | Yes | Yes | No | Yes | Yes | | [PostgreSQL](/databases/postgresql) | 5432 | Yes | Yes | Yes | Yes | Yes | Yes | | [Amazon Redshift](/databases/redshift) | 5439 | Yes | Yes | Yes | No | Yes | Yes | | [CockroachDB](/databases/cockroachdb) | 26257 | Yes | Yes | Yes | No | Yes | Yes | @@ -128,6 +129,7 @@ Metadata connections, the extra ones TablePro opens to read a database's object | [SurrealDB](/databases/surrealdb) | 8000 | Yes | Yes | Yes | No | Yes | Yes | | [Elasticsearch](/databases/elasticsearch) | 9200 | No | Yes | No | No | No | No | | [Typesense](/databases/typesense) | 8108 | No | Yes | No | No | No | No | +| [Weaviate](/databases/weaviate) | 8080 | No | Yes | No | No | No | No | | [Snowflake](/databases/snowflake) | 443 | No | No | No | No | No | No | | [SQLite](/databases/sqlite) | File | No | No | No | No | No | No | | [DuckDB](/databases/duckdb) | File | No | No | No | No | No | No | diff --git a/docs/connections/ssh-tunneling.mdx b/docs/connections/ssh-tunneling.mdx index 84626b658d..fbdbc365d1 100644 --- a/docs/connections/ssh-tunneling.mdx +++ b/docs/connections/ssh-tunneling.mdx @@ -37,7 +37,7 @@ To share one SSH config across connections, save it with **Save Current as Profi Network section with SSH Tunnel selected and a saved profile in the Profile picker -**SSH Tunnel** is not offered on SQLite, PGlite, libSQL, Beancount, BigQuery, Spanner, Cloudflare D1, Cloudflare R2 SQL, DynamoDB, Elasticsearch, Typesense, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API. +**SSH Tunnel** is not offered on SQLite, PGlite, libSQL, Beancount, BigQuery, Spanner, Cloudflare D1, Cloudflare R2 SQL, DynamoDB, Elasticsearch, Typesense, Weaviate, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API. ## Authentication methods diff --git a/docs/connections/ssl.mdx b/docs/connections/ssl.mdx index 470bb8c21c..81b6c35e8e 100644 --- a/docs/connections/ssl.mdx +++ b/docs/connections/ssl.mdx @@ -43,7 +43,7 @@ A new connection starts on the mode that matches the driver's own default, and t | MySQL, MariaDB | Preferred | Tries TLS, then retries plain on an SSL handshake error. Auth and network errors are not retried | | SQL Server | Preferred | FreeTDS `encryption=request`, falls back to plain | | Teradata | Disabled | Opens a TLS transport, retries on a plain socket if it fails to come up | -| MongoDB, Redis, Cassandra, ClickHouse, Elasticsearch, Typesense, SurrealDB | Disabled | Nothing. No fallback exists, so Preferred forces TLS exactly like Required | +| MongoDB, Redis, Cassandra, ClickHouse, Elasticsearch, Typesense, SurrealDB, Weaviate | Disabled | Nothing. No fallback exists, so Preferred forces TLS exactly like Required | | etcd | Disabled | Nothing. The driver never reads these fields. Set **TLS Mode** on the Options section instead, and see [etcd](/databases/etcd) | | Trino | Disabled | Sends every request over HTTPS with no fallback, again like Required | | Oracle | Disabled | Connects in plain TCP, so it behaves like Disabled. A red warning appears under the picker; use Required to enforce TCPS | diff --git a/docs/connections/urls.mdx b/docs/connections/urls.mdx index b2cc1cc16a..8ec370e575 100644 --- a/docs/connections/urls.mdx +++ b/docs/connections/urls.mdx @@ -19,6 +19,7 @@ Two readers take these URLs, and not the same set. The **Import from URL…** sh | `mysql://` | MySQL | Yes | | `mariadb://` | MariaDB | Yes | | `tidb://` | TiDB | Yes | +| `oceanbase://` | OceanBase | Yes | | `sqlite://` | SQLite | Yes | | `mongodb://` | MongoDB | Yes | | `mongodb+srv://` | MongoDB (SRV) | Yes | diff --git a/docs/customization/appearance.mdx b/docs/customization/appearance.mdx index d9b6bf6e73..502011b2d7 100644 --- a/docs/customization/appearance.mdx +++ b/docs/customization/appearance.mdx @@ -1,6 +1,6 @@ --- title: Appearance -description: Themes, per-theme fonts, color customization, and connection colors +description: Themes, color customization, theme files, and connection colors --- Light and Dark each hold their own theme. The **Editing** control at the top of the tab says which of the two the theme list writes to, so setting your dark theme never means leaving Light mode; **Appearance** beside it picks the one in use, or **Auto** to follow the system. @@ -12,28 +12,23 @@ Light and Dark each hold their own theme. The **Editing** control at the top of ## Themes -Four ship with the app: Default Light, Default Dark, and two more dark ones, Dracula and Nord. One theme carries the editor and syntax colors, the data grid colors, the interface colors, and both fonts. +Four ship with the app: Default Light, Default Dark, and two more dark ones, Dracula and Nord. A theme carries the editor and syntax colors, the data grid colors, and the status colors. -The list groups them into Built-in, Registry, and Custom, and shows the ones that suit the slot you are editing plus whatever that slot already holds. Click a row to assign it; it applies at once if that slot is the one in use. +A theme declares whether it is light or dark, and only appears in the matching slot, plus whatever that slot already holds. Click a row to assign it; it applies at once if that slot is the one in use. -The editor on the right always edits the **active** theme, not the row you highlighted. Switch **Editing** to the slot in use before you change a font or a color, or the change lands on the other theme. +The editor on the right edits the theme selected in the slot named by **Editing**, so a change always lands on the theme you can see highlighted. | Control | What it does | |--------|--------| -| **+** menu | **New Theme** copies the active theme; **Import…** reads a theme JSON file | +| **+** menu | **Duplicate Selected** copies the selected theme; **Import…** reads a theme JSON file | | **-** button | Deletes the selected theme. Custom themes only | -| **Gear** menu | **Duplicate** and **Export…** act on the active theme; **Uninstall** removes the selected registry theme | +| **Gear** menu | **Duplicate** and **Export…** act on the selected theme; **Uninstall** removes the selected registry theme | -A built-in or registry theme is read-only, so the first change forks it. Changing a font makes "Name (Custom)" and edits that; **Duplicate**, **New Theme**, and the **Duplicate Theme** button on the Colors tab make "Name (Copy)" for you to edit. +A built-in or registry theme is read-only and shows a **Duplicate Theme** button, which makes "Name (Copy)" for you to edit. -## Fonts +Fonts are not part of a theme. Editor Font lives in [Editor settings](/customization/editor-settings) and Data Grid Font in [Data settings](/customization/data-settings), and both stay on the Mac you set them on. -The pickers list the monospaced families installed on your Mac; the preview renders a sample query in the theme's own editor colors. - -| Setting | Options | Default | Applies to | -|---------|---------|---------|------------| -| Editor Font | Family + size, 11 to 18 pt | System Mono, 13 pt | SQL editor and the JSON viewer's Text mode | -| Data Grid Font | Family + size, 10 to 18 pt | System Mono, 13 pt | Grid cells, the inspector, the cell popovers and the Compare row diff | +A theme file the app cannot read is listed under **Not Loaded** with the reason, rather than dropped without a word. ## Colors @@ -41,52 +36,52 @@ Custom themes get color wells here. A built-in or registry theme shows a lock an | Group | Controls | |-------|----------| -| Editor | Background, text, cursor, current line, selection, line number, invisibles | +| Editor | Background, text, cursor, selection, current line, current statement, line number, invisibles | | Syntax Colors | Keyword, string, number, comment, NULL, operator, function, type | -| Data Grid | Background, text, alternate row, NULL value, bool true/false, row number, modified/inserted/deleted rows, deleted text, focus border | -| Interface | Window, control, and card backgrounds, border, primary/secondary/tertiary text, selection, hover | -| Status Colors | Success, warning, error, info | -| Badges | Badge background, primary key, auto increment | -| Sidebar | Background, text, selected item, hover, section header | -| Toolbar | Secondary and tertiary text | +| Data Grid | Background, text, alternate row, header background, header text, grid line, selection, selected text, inactive selection, focus border, NULL value, bool true/false, row number, modified/inserted/deleted rows, deleted text | +| Panels | Pane background, field background, pane text, secondary text, tertiary text, separator | +| Status | Success, warning, error | + +A slot holds either a hex color or the name of a macOS system color. Default Light and Default Dark keep the data grid surrounds on system colors, which is why an untouched theme follows your system accent and its Increase Contrast setting. Right-click a well that a built-in leaves on a system color to put that system color back. -Interface, Sidebar, and Toolbar colors sit on macOS system colors until you override one, which is why an untouched theme follows your system accent color. An overridden well grows a reset button that puts the system color back. +A theme colors the panes that show database content. The sidebar, the toolbar, the tab strip and the window frame stay on the macOS appearance, which is what the **Appearance** control at the top of the tab sets: pick a dark theme and the window frame goes dark with it. ## Theme files -A theme is one JSON file. **Export…** in the gear menu writes the active theme with every key filled in, which is the shortest way to start a new one; **Import…** in the **+** menu reads one back into `~/Library/Application Support/TablePro/Themes/`. Community themes install from the [registry](/features/plugins) under **Settings > Plugins > Browse**, Themes category. +A theme is one JSON file. **Export…** in the gear menu writes every color the selected theme sets, which is the shortest way to start a new one; **Import…** in the **+** menu reads one back into `~/Library/Application Support/TablePro/Themes/`. Community themes install from the [registry](/features/plugins) under **Settings > Plugins > Browse**, Themes category. | Key | Holds | |-----|-------| +| `schema` | Format version. `2` is the version this release reads | | `id`, `name`, `author` | Identity. An `id` that collides with a built-in, a registry theme, or one you already have is replaced on import | -| `version` | Format version, `1` | -| `appearance` | `light`, `dark`, or `auto`. Decides which slot lists the theme; `auto` lists in both | -| `editor` | Editor colors, with syntax colors nested under `syntax` | -| `dataGrid` | Grid colors | -| `ui` | Interface colors, with `status` and `badges` nested | -| `sidebar`, `toolbar` | Sidebar and toolbar colors | -| `fonts` | `editorFontFamily`, `editorFontSize`, `dataGridFontFamily`, `dataGridFontSize` | +| `appearance` | `light` or `dark`. Decides which slot lists the theme | +| `content.editor` | Editor colors, with syntax colors nested under `syntax` | +| `content.dataGrid` | Grid colors | +| `content.panel` | Colors of the panes around the editor and the grid: results, inspector, structure, compare, query plan | +| `content.status` | Success, warning and error colors | ```json { + "schema": 2, "id": "user.midnight", "name": "Midnight", - "version": 1, - "appearance": "dark", "author": "You", - "editor": { - "background": "#2E3440", - "text": "#D8DEE9", - "currentStatementHighlight": "#FFFFFF0F", - "syntax": { "keyword": "#81A1C1", "string": "#A3BE8C" } - }, - "fonts": { "editorFontFamily": "System Mono", "editorFontSize": 13 } + "appearance": "dark", + "content": { + "editor": { + "background": "#2E3440", + "text": "#D8DEE9", + "syntax": { "keyword": "#81A1C1", "string": "#A3BE8C" } + }, + "dataGrid": { "background": "system:textBackground" }, + "status": { "error": "#BF616A" } + } } ``` -Colors are sRGB hex, `#RRGGBB` or `#RRGGBBAA` for a translucent overlay such as a selection band. A value that is neither renders as the system label color, which is how a typo shows up. +A color is sRGB hex, `#RRGGBB` or `#RRGGBBAA` for a translucent overlay such as a selection band, or `system:` followed by a macOS system color name such as `system:textBackground`. -Every key is optional. A group you leave out falls back to Default Light, and a color left out of `ui`, `sidebar`, or `toolbar` is the macOS system color rather than a fixed value. +Every color a theme declares must be one TablePro reads, and every color TablePro reads must be present. A file that misses one, names one TablePro does not use, carries a color the app cannot parse, or declares a `schema` this release does not read is refused and listed under **Not Loaded** with the reason. Themes written for an earlier version of TablePro are not read and need to be written again. ## Connection colors diff --git a/docs/customization/data-settings.mdx b/docs/customization/data-settings.mdx index e4636fda75..593a1d4c88 100644 --- a/docs/customization/data-settings.mdx +++ b/docs/customization/data-settings.mdx @@ -1,17 +1,25 @@ --- title: Data Settings -description: Data grid, pagination, query result cap, JSON viewer, and query history +description: Data grid font, pagination, query result cap, JSON viewer, and query history --- Browsing a table pages 1,000 rows at a time, and a query that returns more than 10,000 rows stops there. Both numbers, and the rest of what the grid does before you touch anything, are on **Settings > Data**. -The tab is app-wide, so these values apply to every connection you open; filters, column widths, and the sort you clicked are remembered per table instead. Grid fonts are per-theme, on the theme's Fonts tab under [Appearance](/customization/appearance). +The tab is app-wide, so these values apply to every connection you open; filters, column widths, and the sort you clicked are remembered per table instead. The font here is set per Mac, not per theme, and is not synced. Data settings tab Data settings tab +## Font + +| Setting | Default | Applies to | +|---------|---------|------------| +| Data Grid Font | System Mono, 13 pt | Grid cells, the inspector, the cell popovers and the Compare row diff | + +The picker lists the monospaced families installed on your Mac, at 10 to 24 pt. + ## Data Grid | Setting | Default | Notes | diff --git a/docs/customization/editor-settings.mdx b/docs/customization/editor-settings.mdx index c4367eae85..5b2ea15c38 100644 --- a/docs/customization/editor-settings.mdx +++ b/docs/customization/editor-settings.mdx @@ -1,27 +1,35 @@ --- title: Editor Settings -description: SQL editor, Vim mode, line numbers, indentation, and query parameter settings +description: SQL editor font, Vim mode, line numbers, indentation, and query parameter settings --- **Settings > Editor** covers typing and how SQL is displayed. Grid, pagination, result cap and JSON viewer settings are on the [Data](/customization/data-settings) tab instead. -Editor fonts are per-theme. Edit them on the theme's Fonts tab under [Appearance](/customization/appearance). +The font here is set per Mac, not per theme, and is not synced. Editor settings Editor settings +## Font + +| Setting | Default | Applies to | +|---------|---------|------------| +| Editor Font | System Mono, 13 pt | SQL editor, the JSON viewer's Text mode, and the DDL, trigger and import previews | + +The picker lists the monospaced families installed on your Mac, at 10 to 24 pt. `Cmd+=` and `Cmd+-` change the size over the same range. + ## SQL editor | Setting | Default | Notes | |---------|---------|-------| -| Show line numbers | On | | +| Show line numbers | On | Turning this off also hides the per-statement run button | | Highlight current line | On | | | Highlight current statement | On | A faint band behind the statement the cursor is in. See [Statement markers](/features/sql-editor#statement-markers) | | Word wrap | Off | Off means long lines scroll horizontally | | Code folding | On | Shows the fold ribbon in the gutter. See [Code Folding](/features/code-folding) | -| Run button beside each statement | On | Gutter run buttons, revealed when the pointer is over the gutter | +| Run button beside each statement | On | Gutter run buttons, revealed when the pointer is over the gutter. Needs line numbers on | | Tab width | 4 spaces | 2, 4, or 8 | | Auto-uppercase keywords | Off | Uppercases SQL keywords on word boundaries. Strings, comments, and quoted identifiers are untouched | | Query parameters (`:name` syntax) | On | Detects `:name` placeholders and shows the parameter panel. See [Query Parameters](/features/query-parameters) | diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index b1e0b7271c..cdad509d32 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -12,7 +12,7 @@ The settings window is app-wide: a value on any of these eleven tabs holds for e Language, startup, tabs, sidebar, query timeout, updates, reset. - Appearance mode, themes, per-theme fonts and colors. + Appearance mode, themes and theme colors. SQL editor, Vim mode, line numbers, query parameters. Data grid, pagination, result cap, JSON viewer, and query history. Custom shortcuts. @@ -47,8 +47,7 @@ What a fresh install ships with, so one glance down this column says what you ch | Appearance | Appearance | Auto | | Appearance | Light theme | Default Light | | Appearance | Dark theme | Default Dark | -| Appearance | Editor font | System Mono, 13 pt | -| Appearance | Data grid font | System Mono, 13 pt | +| Editor | Editor font | System Mono, 13 pt | | Editor | Show line numbers | On | | Editor | Highlight current line | On | | Editor | Highlight current statement | On | diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index f9f39f7613..3d8bb546b3 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -86,6 +86,7 @@ The CA file may be PEM or DER. If **Verify CA** cannot read it, the connection f - No auto-increment, and the primary key and sorting key are fixed at creation. Structure editing covers adding, modifying, and dropping columns and data-skipping indexes; recreate the table for anything else. - A `SET` does not carry to the next statement. Every statement is its own HTTP request with no session id, and the setting is gone by the next one. Put it in a `SETTINGS` clause on the query itself. - A query with its own `FORMAT` clause, such as `SELECT 1 FORMAT JSON`, shows the server's raw output in one column instead of a parsed table. Drop the clause to get a grid. +- A column with even one value that is not valid UTF-8 shows every row as hex in the grid, and its cells do not open for inline editing. `String` and `FixedString` hold raw bytes, so a hash or a stray byte is enough. An export, an object copy and a data compare judge each value on its own, so a row holding text stays text there. Select `toValidUTF8(column)` to read it as text, with the bad bytes replaced. ## Troubleshooting diff --git a/docs/databases/index.mdx b/docs/databases/index.mdx index c0d2e822b9..0966a59842 100644 --- a/docs/databases/index.mdx +++ b/docs/databases/index.mdx @@ -1,11 +1,11 @@ --- title: Supported Databases -description: All 34 engines TablePro connects to, their default ports, and which ones need a plugin +description: All 36 engines TablePro connects to, their default ports, and which ones need a plugin --- import DriverCounts from "/snippets/driver-counts.mdx"; -Thirty-four engines, and every one of them is free to use. What differs between them is where the +Thirty-six engines, and every one of them is free to use. What differs between them is where the driver comes from, not what the license covers. @@ -34,6 +34,7 @@ driver comes from, not what the license covers. | [Microsoft SQL Server](/databases/mssql) | 1433 | Plugin | | [MongoDB](/databases/mongodb) | 27017 | Plugin | | [MySQL](/databases/mysql) | 3306 | Built-in | +| [OceanBase](/databases/oceanbase) | 2881 | Built-in | | [Oracle Database](/databases/oracle) | 1521 | Plugin | | [PGlite](/databases/pglite) | 5432 | Built-in | | [PostgreSQL](/databases/postgresql) | 5432 | Built-in | @@ -48,10 +49,11 @@ driver comes from, not what the license covers. | [Trino](/databases/trino) | 8080 | Plugin | | [Typesense](/databases/typesense) | 8108 | Plugin | | [Turso](/databases/libsql) | API-based | Plugin | +| [Weaviate](/databases/weaviate) | 8080 | Plugin | Rows sharing a page share a driver. MariaDB reads as MySQL, ScyllaDB as Cassandra, Turso as libSQL, -and Redshift, CockroachDB and PGlite all speak the PostgreSQL wire protocol. TiDB and Databend -also run on the bundled MySQL driver. +and Redshift, CockroachDB and PGlite all speak the PostgreSQL wire protocol. TiDB, Databend and +OceanBase also run on the bundled MySQL driver. ## Built-in against plugin diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 40e694b46d..a45410425c 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -3,7 +3,7 @@ title: MySQL description: Connect to MySQL 5.7 and later with the bundled MariaDB Connector/C driver --- -MySQL 8 accounts on `caching_sha2_password` connect on the first try, with no auth plugin to switch over. The same bundled driver covers 5.7 and later, plus [MariaDB](/databases/mariadb), [TiDB](/databases/tidb), and [Databend](/databases/databend). +MySQL 8 accounts on `caching_sha2_password` connect on the first try, with no auth plugin to switch over. The same bundled driver covers 5.7 and later, plus [MariaDB](/databases/mariadb), [TiDB](/databases/tidb), [Databend](/databases/databend), and [OceanBase](/databases/oceanbase). ## Quick setup @@ -26,7 +26,7 @@ MySQL 8 accounts on `caching_sha2_password` connect on the first try, with no au | **Password** | empty | Stored in the macOS Keychain | | **Database** | empty | Optional. Leave it empty to browse every database | -The session character set is `utf8mb4`, so emoji and non-Latin text round-trip untouched. A connect attempt gives up after 10 seconds. +Every connection sets its session character set to `utf8mb4` once it logs in, over any `init_connect` the server runs, so emoji and non-Latin text round-trip untouched. A connect attempt gives up after 10 seconds. ## Connection URL @@ -74,7 +74,47 @@ To stop the check itself, set **Check connections** to **Only when I use the con Reconnecting costs a TCP connect, the TLS handshake and authentication: measured at 2ms against a server on the same machine and 800ms to 1.9s across the internet. That cost lands on the first query after an idle period, so leave this at `0` for a remote server unless the slot matters more than the wait. -A release is refused, with the reason, while the session holds anything a reconnect would destroy: an open transaction, a temporary table, a prepared statement, a `GET_LOCK`, `LOCK TABLES`, a user variable, a changed session setting, or a stored routine call, whose body is opaque. +A release is refused, with the reason, while the session holds anything a reconnect would destroy: an open transaction, a temporary table, a prepared statement, a `GET_LOCK`, `LOCK TABLES`, `FLUSH TABLES WITH READ LOCK`, an open `HANDLER`, a user variable, a changed session setting, a database selected with `USE`, or a stored routine call, whose body is opaque. The statements a dump runs inside `/*!40101 ... */` count, since the server executes them. The transaction comes from the server itself, so one opened by `SET autocommit = 0` or by an `XA START` counts as well. + +## When the server drops the connection + +A read that fails because the server closed the connection is run once more on a new one, and only from a session holding nothing: the same list as above. Every other statement reports the error, because the second session answers from different state. Measured on MySQL 8.4.11, replaying regardless: `SELECT @total` came back `NULL` where it had come back 42, and `SELECT DATABASE()` came back as the connection's own database rather than the one `USE` had selected, neither of them raising anything. `LAST_INSERT_ID()`, `ROW_COUNT()`, `FOUND_ROWS()` and `CONNECTION_ID()` are never replayed either, whatever the session holds: their answer belongs to the connection that is gone. + +A connection with startup commands that run `SET` is holding a changed session setting from the moment it opens, so it reports the error rather than retrying. **Database > Reconnect** takes a new connection and puts back the database and the startup commands. + +## Garbled non-Latin text + +A comment or value that reads `メール` where `メール` belongs was written by a client that sent UTF-8 while telling the server it was sending Latin 1. A `mysql` command-line client without a UTF-8 locale does that, and so does a MySQL 5.7 container loading its `docker-entrypoint-initdb.d` scripts, and so does any client on a server whose `init_connect` runs `SET NAMES latin1`. The server stored the garbled form, so every UTF-8 client shows the same thing. + +To work with such a database the way that client did, set **Encoding** to **UTF-8 via Latin 1** in **Options** and reconnect. Text written through Latin 1 then reads correctly, text stored correctly still reads correctly, and whatever you save is stored the way the old client stored it, so the application that wrote the data keeps reading it. + + +With **UTF-8 via Latin 1**, a correctly stored value you edit is saved in the garbled form, and a table or column whose non-Latin name was stored correctly cannot be opened. Use it on a database written through Latin 1, never on one where applications write UTF-8. + + +An SQL export taken with **UTF-8 via Latin 1** holds the text as it reads, in UTF-8. Restoring it gives a database with the text fixed, which the old client then reads as `?`. Restore it where you are moving off that client, not as a backup of the database it still writes to. + +To fix the stored text instead, convert it in place. The `WHERE` clause skips values that were stored correctly: + +```sql +UPDATE orders +SET note = CONVERT(CAST(CONVERT(note USING latin1) AS BINARY) USING utf8mb4) +WHERE note = CONVERT(CONVERT(note USING latin1) USING utf8mb4) + AND CONVERT(CAST(CONVERT(note USING latin1) AS BINARY) USING utf8mb4) IS NOT NULL; +``` + +Table comments take one `ALTER TABLE` each. This query writes them for the current database; run the statements it returns: + +```sql +SELECT CONCAT('ALTER TABLE `', TABLE_NAME, '` COMMENT = ', + QUOTE(CONVERT(CAST(CONVERT(TABLE_COMMENT USING latin1) AS BINARY) USING utf8mb4)), ';') +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_COMMENT <> '' + AND TABLE_COMMENT = CONVERT(CONVERT(TABLE_COMMENT USING latin1) USING utf8mb4) + AND CONVERT(CAST(CONVERT(TABLE_COMMENT USING latin1) AS BINARY) USING utf8mb4) IS NOT NULL; +``` + +A column comment is part of the column's definition. Copy it from the **Structure** tab while **UTF-8 via Latin 1** is on, then switch back to **UTF-8**, reconnect, and paste it into the same column. ## SSL/TLS diff --git a/docs/databases/oceanbase.mdx b/docs/databases/oceanbase.mdx new file mode 100644 index 0000000000..5537d8f629 --- /dev/null +++ b/docs/databases/oceanbase.mdx @@ -0,0 +1,59 @@ +--- +title: OceanBase +description: Connect to OceanBase MySQL mode through the bundled MySQL driver on port 2881 +--- + +Sign in as `user@tenant`. The sys tenant's root is `root@sys`. Oracle compatibility mode is a different protocol and does not connect. + +No minimum OceanBase version is enforced. Host, password, SSH tunnels, SSL/TLS and `Cmd+K` database switching work as on [MySQL](/databases/mysql). + +## Connection settings + +| Field | Default | Notes | +|-------|---------|-------| +| **Port** | `2881` | The SQL port for MySQL mode. Do not point at an OBProxy extras port unless that is the SQL listener | +| **Database** | empty | Optional. Leave it empty to browse every database in the tenant | +| **SSL Mode** | Preferred | TLS first, plain text if the server refuses | +| **Username** | empty | `user@tenant`. In a URL, write the `@` as `%40`: `root%40sys` | + +**Release the Server Connection After** is available under Advanced. AWS IAM, Cloud SQL Auth Proxy and Unix socket are not offered; reach a private cluster through an [SSH tunnel](/connections/ssh-tunneling). + +## Connection URL + +```text +oceanbase://root%40sys:password@host:2881/database +``` + +`oceanbase+ssh://` opens it through an SSH tunnel. See [Connection URL Reference](/connections/urls). + +## Tables without a primary key + +OceanBase keeps a hidden `__pk_increment` column on a table declared without a primary key, but the catalog never reports it and a plain `SELECT *` never returns it, so TablePro edits such a table the way it edits one on [MySQL](/databases/mysql): the `UPDATE` or `DELETE` matches every column of the row, and a save that would touch more than one row is rolled back with a message that identical rows cannot be told apart. + +The connection type decides the sidebar, the type picker, EXPLAIN and Stop. A server reached through a connection saved as **MySQL** is treated as MySQL, because the MySQL handshake reports `5.7.25` and names no engine. Choose **OceanBase** for what this page describes. + +Opening an OceanBase connection reads `@@version_comment`. A server that does not name OceanBase there is refused, so a mistyped host or port fails at connect instead of part way through a session. + +## What the MySQL driver does not copy + +- The sidebar hides `information_schema`, `mysql` and `oceanbase`. User databases such as `test` stay listed. +- **Stop** sends `KILL QUERY` for the session's own id, as on MySQL. +- `EXPLAIN` is plain text. `EXPLAIN FORMAT=JSON` is not offered. +- The type picker has MySQL's types without the Spatial group. +- [Users & Roles](/features/users-roles) has no connection limit field. +- Table Maintenance offers `ANALYZE TABLE` only. +- Foreign keys can be created. `SET FOREIGN_KEY_CHECKS` is not sent. Triggers appear in the Structure tab; adding one from there is not offered. + +## Limitations + +- Oracle compatibility mode does not connect. Use a MySQL-mode tenant and the MySQL protocol port. +- No Server Dashboard, and **File > Backup Dump…** stays dimmed. +- [Compare & Sync](/features/compare-sync) writes no structure script between OceanBase and MySQL or MariaDB. OceanBase against OceanBase works. +- Cluster tenant administration is out of scope. + +## Related + +- [MySQL](/databases/mysql), for connection fields, SSL/TLS, and troubleshooting +- [MariaDB](/databases/mariadb) +- [TiDB](/databases/tidb) +- [SSH Tunneling](/connections/ssh-tunneling) diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index d98bbd3dff..1ec304caf9 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -1,6 +1,6 @@ --- title: PostgreSQL -description: Connect to PostgreSQL 12 and later with the libpq driver, including PostGIS display, AWS IAM auth, and pg_dump backups +description: Connect to PostgreSQL 9.1 and later with the libpq driver, including PostGIS display, AWS IAM auth, and pg_dump backups --- Unlike MySQL, PostgreSQL will not connect without a **Database**, and it changes database only by reconnecting. Everything else on the form is ordinary. The libpq driver ships inside the app and also serves [Amazon Redshift](/databases/redshift), [CockroachDB](/databases/cockroachdb), and [PGlite](/databases/pglite). @@ -16,7 +16,14 @@ Unlike MySQL, PostgreSQL will not connect without a **Database**, and it changes Fill in the form and click **Save & Connect**. For remote servers use [SSH tunneling](/connections/ssh-tunneling); Google Cloud SQL instances can connect through the [Cloud SQL Auth Proxy](/connections/cloud-sql-proxy). -Support starts at 12. An older server still connects, with whatever its catalogs carry: generated columns need 12, declarative partitioning and identity columns need 10. +Any server from 9.1 on works. A feature the server predates stays out of the sidebar and the structure editor: + +| Feature | Needs | +|---|---| +| Materialized views | 9.3 | +| BRIN indexes, the **Bypass row level security** role attribute | 9.5 | +| Declarative partitioning, identity columns, renaming an enum label | 10 | +| Generated columns | 12 | PostgreSQL connection form @@ -66,10 +73,26 @@ In the list editor, reorder rows with the arrows, add and remove elements, and s Enums, composites, domains and ranges are listed under **Types** in each schema, the `CREATE` statement rebuilt from `pg_type`. An enum's labels are edited in place with `ALTER TYPE … ADD VALUE` and, from PostgreSQL 10, `RENAME VALUE`; PostgreSQL has no statement that drops or reorders a label. The structure editor's type picker offers the schema's types under **User-Defined**. See [User-Defined Types](/features/user-defined-types). +## Views and comments + +A view's definition is rebuilt to run anywhere. Every table it reads is schema-qualified, and `WITH (security_barrier)`, `WITH (security_invoker)` and `WITH CASCADED CHECK OPTION` are written back, so executing the statement elsewhere keeps the restrictions the original carried. Read it with **Show DDL**, copy it with **Copy DDL**, or edit it with **Edit View Definition**. A materialized view's statement carries its access method, storage parameters and tablespace, and the DDL tab adds its indexes. + +`REFRESH MATERIALIZED VIEW` runs from **Refresh Materialized View…**. A plain refresh holds an `ACCESS EXCLUSIVE` lock, so nothing reads the view until it finishes. **Refresh concurrently** takes `EXCLUSIVE` instead and leaves readers working, and PostgreSQL accepts it only for a populated view with a valid unique index on plain columns, no `WHERE` clause and no expressions; a partial or expression index does not qualify. The refresh runs on its own connection, outside any transaction a query editor holds open. Refreshing needs ownership of the view, or the `MAINTAIN` privilege from PostgreSQL 17. + +**Edit Comment…** writes `COMMENT ON`, picking `TABLE`, `VIEW`, `MATERIALIZED VIEW` or `FOREIGN TABLE` to match the object. Column comments are written from the Columns tab as `COMMENT ON COLUMN`. Clearing the field writes `IS NULL`, which removes the comment. Only the object's owner may comment on it. + +An SQL export writes those statements back for tables, views and materialized views, so a dump and a restore keep their comments. See [Import & Export](/features/import-export). + +The Structure tab of a view, a materialized view or a foreign table offers only the edits PostgreSQL takes on that kind of object, which is fewer than a table's and different for each. See [Table Structure](/features/table-structure#what-each-object-accepts). + ## Cross-database tabs PostgreSQL has no in-place `USE`, so a tab bound to a database other than the connection's active one runs on a second connection opened for that database. It shares no temp tables, session variables, or open transaction with the query editor on the main connection: keep a multi-statement transaction or a `CREATE TEMP TABLE` on tabs bound to one database. Binding itself is on [Tabs](/features/tabs#where-a-tab-points). +## Text encoding + +Sessions run in `UTF8` whatever encoding the database was created with, and the server converts text both ways. Japanese in an `EUC_JP` database or accented names in a `LATIN1` one read and save intact, and stay that way after `RESET ALL` or `DISCARD ALL` in a query tab. A `-c client_encoding=…` in **Connection Options** is overridden; other settings there still apply. + ## Tools `EXPLAIN` and `EXPLAIN ANALYZE` run with `FORMAT JSON` and render as a plan diagram or tree. See [EXPLAIN Visualization](/features/explain-visualization). **Database > Users & Roles** shows where each privilege comes from before you grant or revoke it. **Backup Dump** and **Restore Dump** shell out to your local `pg_dump` and `pg_restore`; see [Backup & Restore](/features/backup-restore). @@ -82,7 +105,8 @@ New connections default to **Preferred** (libpq `sslmode=prefer`): TLS first, pl - Columns cannot be reordered. The structure editor adds, renames, retypes, and drops; changing the order of existing columns means recreating the table. - A cross-database tab cannot share session state with the main connection. Statements that depend on a temp table or an open transaction have to run on one database. -- Backup and restore need `pg_dump` and `pg_restore` on your Mac. Neither is bundled; install them with Homebrew. +- Backup and restore need `pg_dump` and `pg_restore` on your Mac. Neither is bundled; install them with Homebrew. `pg_dump` 15 and later refuse PostgreSQL 9.1, so back one up with `pg_dump` 14 or earlier. +- Before PostgreSQL 12, the [`run_maintenance`](/external-api/mcp-tools) tool's `REINDEX` with no `table` runs without `CONCURRENTLY` and rebuilds the system catalog indexes as well. While an index rebuilds, writes to its table wait, and so do reads that use that index. Run it when the database is quiet. ## Troubleshooting @@ -90,4 +114,6 @@ New connections default to **Preferred** (libpq `sslmode=prefer`): TLS first, pl **FATAL: password authentication failed for user "…"**: the role and password are checked against `pg_hba.conf`. Confirm the method on the matching line (`scram-sha-256` or `md5` for passwords, `trust` for local dev), and that the line matches the host you are connecting from. +**ERROR: invalid byte sequence for encoding "UTF8": 0x…**: the database's encoding is `SQL_ASCII`, which stores bytes without checking them, and a value in the result is not UTF-8. Run `SHOW server_encoding` to confirm. Move the data to a `UTF8` database: dump it with `pg_dump --encoding=` and the encoding the text was written in, such as `EUC_JP` or `WIN1252`, then restore that dump into a database created with `ENCODING 'UTF8'`. + **A Postgres-compatible engine loads no tables**: wire-compatible engines connect under the PostgreSQL type, and the catalogs they omit are probed for rather than assumed. An engine without `pg_matviews` still lists its tables; object kinds it does not expose will not appear at all. diff --git a/docs/databases/weaviate.mdx b/docs/databases/weaviate.mdx new file mode 100644 index 0000000000..0c847bc217 --- /dev/null +++ b/docs/databases/weaviate.mdx @@ -0,0 +1,132 @@ +--- +title: Weaviate +description: Connect to Weaviate, browse collections, edit objects by uuid, and run GraphQL +--- + +import RegistryPlugin from "/snippets/registry-plugin.mdx"; + +Collections are tables, objects are rows, and `uuid` is the primary key. The editor speaks GraphQL, not SQL. Vector and hybrid search go through `/v1/graphql`. Browse and row edits use `/v1/objects`. + + + +## Quick setup + +Click **New Connection…**, select **Weaviate**, enter host and port, pick an **Auth Method**, then click **Save & Connect**. + +There is no Database field. One connection reaches one Weaviate instance, and its collections are the objects. + +## Connection settings + +| Field | Description | +|-------|-------------| +| **Host** | Node hostname. `localhost` for Docker, a Weaviate Cloud hostname for hosted clusters | +| **Port** | `8080` by default. Weaviate Cloud answers on `443` | +| **Auth Method** | None, or API Key | +| **API Key** | Sent as an `Authorization: Bearer` header, and only when **Auth Method** is API Key | +| **Skip TLS Verification** | Advanced section. Trusts any certificate, even under **Verify CA** or **Verify Identity** | + +## Authentication + +| Auth Method | What is sent | +|-------------|--------------| +| **None** | Nothing. For a local node with anonymous access | +| **API Key** | An `Authorization: Bearer` header. Paste the Weaviate Cloud key, or the key the local node was started with | + +Weaviate Cloud requires an API key and HTTPS. Set **SSL Mode** to **Required** (or **Verify Identity** if you have the CA) and **Auth Method** to API Key. + +## Browsing collections + +The sidebar lists collections from `GET /v1/schema`. + +Every grid has `uuid` first and `vector` last. `uuid` is the primary key. Both columns are read-only. Property columns come from the collection schema. Arrays and objects render as JSON in the cell. + +A vector is shown as a JSON number array. It is not a SQL BLOB and the inline editor does not write it back. + +Unfiltered pages load through `GET /v1/objects`. A column filter or a sort becomes a GraphQL `Get` with `where` and `sort`. + +## Filtering + +Filter values are typed from the collection schema: an `int` property filters as a number, a `date` property needs a full RFC 3339 timestamp such as `2024-01-31T00:00:00Z`, and an array property filters on one element. + +| Operators | What Weaviate runs | +|----------|--------------------| +| equals, not equals | `Equal`, `NotEqual` | +| greater than, greater or equal, less than, less or equal | Numbers and dates only | +| contains, not contains, starts with, ends with | `Like` with `*` wildcards, text properties only | +| in list, not in list | `ContainsAny`, `ContainsNone` over a comma-separated list | +| between | Two bounds, both inclusive | +| is NULL, is not NULL | `IsNull`, which needs `indexNullState` on the collection | +| is empty, is not empty | `len()`, which needs `indexPropertyLength` on the collection | + +**REGEX** has no Weaviate equivalent, and a filter Weaviate cannot run reports why instead of returning the whole collection. + +Grid edits are REST calls keyed by `uuid`. + +| Change | Request | +|--------|---------| +| New row | `POST /v1/objects`, with an `id` if you typed one | +| Edited cells | `PATCH /v1/objects/{uuid}?class=Collection` | +| Deleted row | `DELETE /v1/objects/{uuid}?class=Collection` | + +An update or delete with no `uuid` is skipped and logged. + +## GraphQL editor + +Type a GraphQL operation and run it. Weaviate's GraphQL API reads only: `{ Get { … } }` and `query { … }` work, and there is no mutation type. Writes go through REST. + +```graphql +{ + Get { + Article( + nearText: { concepts: ["search term"] } + limit: 10 + ) { + title + _additional { id distance } + } + } +} +``` + +`Get` responses render as a grid of the fields the query selected, with `_additional.id` mapped to `uuid`. The rest of `_additional`, such as `distance` and `score`, each become a column. Anything else, including `Aggregate`, is shown as formatted JSON. + +A REST line also runs, the way the other search drivers do: + +```http +GET /v1/schema +``` + +A path without `/v1` gets it, so `GET /nodes` reaches `/v1/nodes`. A body goes on the same line as the path or on the lines under it. + +## SSL/TLS + +New connections start on **Disabled**, plain HTTP. Every other mode goes over HTTPS. **Preferred** and **Required (skip verify)** accept a self-signed certificate. See [SSL/TLS](/connections/ssl). + +## Limitations + +- Oracle-style SQL is not available. Use GraphQL, or the REST console for `/v1/schema` and `/v1/objects`. +- gRPC is not used. +- Cross-references are properties, not foreign keys. There are no routines, triggers, or schema edits from Structure. +- Multi-tenancy is not exposed. Name a tenant in GraphQL if the collection requires one. +- Paging stops at row 10,000. Weaviate refuses an offset and limit that add up past `QUERY_MAXIMUM_RESULTS`, which defaults to 10,000. +- No [SSH tunnel](/connections/ssh-tunneling). +- The plugin is registry-only. It is not on iPhone or iPad. + +## Troubleshooting + +### Authentication failed: … + +The API key was rejected, or the node expects a key and **Auth Method** is None. Weaviate Cloud needs both HTTPS and an API key. + +### The connection drops after the API key changes + +A revoked or rotated key fails the next health check, because the check reads `/v1/meta` rather than the unauthenticated readiness endpoint. Paste the new key and connect again. + +### Connection failed: … + +A TLS or network failure. For a self-signed certificate set **SSL Mode** to **Required (skip verify)**, or turn on **Skip TLS Verification**. + +## Related + +- [Import & Export](/features/import-export) +- [Filtering](/features/filtering) diff --git a/docs/development/plugin-development.mdx b/docs/development/plugin-development.mdx index 6892661ea4..43315b4a8d 100644 --- a/docs/development/plugin-development.mdx +++ b/docs/development/plugin-development.mdx @@ -13,7 +13,7 @@ A plugin is a macOS loadable bundle target with `WRAPPER_EXTENSION = tableplugin | Key | Type | Required | Purpose | |-----|------|----------|---------| -| `TableProPluginKitVersion` | integer | Yes | The PluginKit ABI the plugin was built against. Current value: 25 | +| `TableProPluginKitVersion` | integer | Yes | The PluginKit ABI the plugin was built against. Current value: 29 | | `TableProProvidesDatabaseTypeIds` | array of strings | Recommended | Database type IDs the plugin serves, which is what makes lazy loading possible | | `CFBundleShortVersionString` | string | Yes | Plugin version, read by registry update checks | | `TableProMinAppVersion` | string | No | The loader rejects the plugin on an older app | diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index cdddf55118..393441a559 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -68,13 +68,13 @@ Themes carry no native code, so they match on architecture alone. "binaries": [ { "architecture": "arm64", - "pluginKitVersion": 25, + "pluginKitVersion": 29, "downloadURL": "https://github.com/TableProApp/TablePro/releases/download/plugin-oracle-v1.0.26/OracleDriver-arm64.zip", "sha256": "" }, { "architecture": "x86_64", - "pluginKitVersion": 25, + "pluginKitVersion": 29, "downloadURL": "https://github.com/TableProApp/TablePro/releases/download/plugin-oracle-v1.0.26/OracleDriver-x86_64.zip", "sha256": "" } @@ -140,9 +140,9 @@ The app fetches the manifest from `raw.githubusercontent.com/TableProApp/plugins Themes use the same manifest with `category: "theme"`. Four things differ from a driver: - Pure JSON data. No executable code, no code signing, no `.tableplugin` bundle -- The ZIP holds `.json` files, each a valid `ThemeDefinition`. Packs with several themes work +- The ZIP holds `.json` files, each a theme file in format `schema: 2`. Packs with several themes work - They install to `~/Library/Application Support/TablePro/Themes/Registry/` -- No `pluginKitVersion` is needed, and the flat v1 fields still decode +- No `pluginKitVersion` is needed. Set `minAppVersion` to the release that reads the format the theme is written in, because that is the only check an older TablePro runs before installing it ```json { diff --git a/docs/docs.json b/docs/docs.json index 43abd690e3..90217053f7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -164,6 +164,7 @@ "databases/mssql", "databases/mongodb", "databases/mysql", + "databases/oceanbase", "databases/oracle", "databases/pglite", "databases/postgresql", @@ -175,7 +176,8 @@ "databases/teradata", "databases/tidb", "databases/trino", - "databases/typesense" + "databases/typesense", + "databases/weaviate" ] }, { diff --git a/docs/external-api/ios-shortcuts.mdx b/docs/external-api/ios-shortcuts.mdx index cd39ca0a0f..a96411b0a9 100644 --- a/docs/external-api/ios-shortcuts.mdx +++ b/docs/external-api/ios-shortcuts.mdx @@ -44,9 +44,9 @@ To browse everything the app offers, open Shortcuts, tap the action list, and go -Inserts work on MySQL, MariaDB, TiDB, PostgreSQL, Redshift, SQL Server, SQLite, DuckDB and Oracle. Any -other type, Redis for example, fails with *"Redis connections do not support adding rows from -Shortcuts."* +Inserts work on MySQL, MariaDB, TiDB, OceanBase, PostgreSQL, Redshift, SQL Server, SQLite, DuckDB and +Oracle. Any other type, Redis for example, fails with *"Redis connections do not support adding rows +from Shortcuts."* Siri takes "Open [connection] in TablePro", "Connect to [connection] in TablePro", "Add a row in TablePro" and "Add rows in TablePro", with a connection name in place of the brackets. diff --git a/docs/external-api/mcp-protocol.mdx b/docs/external-api/mcp-protocol.mdx index 8fc9a54f5d..a62836ae71 100644 --- a/docs/external-api/mcp-protocol.mdx +++ b/docs/external-api/mcp-protocol.mdx @@ -255,6 +255,8 @@ Without `elicitation`, TablePro shows its own confirmation dialog on the user's } ``` +The `description` carries the statement verbatim, line breaks and all, up to 2,000 characters. Render it as a block rather than a one-line label: it is the only thing the person sees before they approve, and a `WHERE` clause that falls off the end changes what they agreed to. + Present the form, then repeat the **same** call with the answer and the state echoed back: ```json @@ -272,6 +274,8 @@ Present the form, then repeat the **same** call with the answer and the state ec A call that carries `inputResponses` or `requestState` is never cached, whatever the method's normal hint says. +An accepted form stands in for TablePro's own dialog only on a `fullAccess` token. On a `readOnly` or `readWrite` token, and on the anonymous loopback principal, Safe Mode still puts its dialog on the Mac afterwards, so a write is confirmed twice: once in your interface, once in TablePro's. Ask for `fullAccess` at [pairing](/external-api/pairing) if the second prompt is not what you want. + ## Errors TablePro's own error codes live in `-33000` and above. The `-32000` block is frozen by the specification and TablePro no longer uses it. The three codes in `-32020` and below are defined by the specification itself. diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 2b78be43f6..32360635e0 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -157,13 +157,13 @@ Formats are `csv`, `json` and `sql`; pass exactly one of `query` or `tables[]`. | Tool | Arguments | Returns | |------|-----------|---------| -| `get_server_dashboard` | `connection_id` (`panels`) | The requested panels: `sessions`, `metrics`, `slow_queries` | +| `get_server_dashboard` | `connection_id` (`panels`) | The requested panels: `sessions`, `metrics`, `slow_queries`, plus `errors` for any the server could not answer | | `stop_server_session` | `connection_id`, `process_id` (`mode`) | The result set the engine produced | | `list_principals` | `connection_id` | `principals[]` (`name`, `host`, `is_role`, `can_login`, `member_of[]`, `connection_limit`, `comment`, and `attributes[]` of `{ key, label, is_enabled }`), sorted by name | | `list_grants` | `connection_id`, `principal` (`host`) | `principal`, `grants[]` (`privilege`, `scope`, `is_grantable`). `scope` is a dotted path, `*` for a server-wide grant | | `list_session_contexts` | `connection_id` | `is_supported`, `contexts[]` (`id`, `label`, `value`, `options[]`) | -`get_server_dashboard` reads the live panels TablePro shows, and runs on PostgreSQL, MySQL, SQL Server, ClickHouse, DuckDB and SQLite. Name the panels in `panels[]` or omit it for all three. A session carries `id` (the process id `stop_server_session` takes), `user`, `database`, `state`, `duration_seconds`, `query`, `can_kill` and `can_cancel`; a metric carries `id`, `label`, `value`, `unit`; a slow query carries `duration`, `query`, `user`, `database`. +`get_server_dashboard` reads the live panels TablePro shows, and runs on PostgreSQL, MySQL, SQL Server, ClickHouse, DuckDB and SQLite. Name the panels in `panels[]` or omit it for all three. A session carries `id` (the process id `stop_server_session` takes), `user`, `database`, `state`, `duration_seconds`, `query`, `can_kill` and `can_cancel`; a metric carries `id`, `label`, `value`, `unit`; a slow query carries `duration`, `query`, `user`, `database`. A panel the server could not answer is left out, and `errors` names it: one fixed sentence per panel, keyed by panel name. The server's own message is not sent to the client; it goes to the app's log, where the user can read it. When every requested panel fails, the call fails. `stop_server_session` takes `mode` as `cancel` (default) to stop the running query or `kill` to end the session. The user always approves this one, whatever Safe Mode says. diff --git a/docs/features/backup-restore.mdx b/docs/features/backup-restore.mdx index 59eba8abd9..9e1b0599e7 100644 --- a/docs/features/backup-restore.mdx +++ b/docs/features/backup-restore.mdx @@ -18,6 +18,8 @@ MariaDB 11.0 renamed its clients, so `mariadb-dump` and `mariadb` are accepted i The lookup takes the first match from `/usr/bin/which`, then `/opt/homebrew/bin`, `/usr/local/bin`, and Postgres.app's latest version. That path decides which client version does the work. +On PostgreSQL that match is checked against the server first, for both directions. `pg_dump` and `pg_restore` refuse a server newer than themselves, 15 and later refuse servers older than 9.2, and TablePro names the database with `-d`, which arrived in 9.3. When the first match cannot reach the server, the newest one that can is taken from the Homebrew `postgresql@…` and `libpq` installs, every Postgres.app version, and `/Library/PostgreSQL`. When none can, the run stops before it starts and names the version to install: PostgreSQL 9.1 needs `pg_dump` 9.3 to 14, so `brew install postgresql@14`. A binary that does not answer `--version` is used anyway. + DuckDB is the exception: its dump is a statement the engine already in front of TablePro runs, over the connection that is already open. **File > Backup Dump…** is dimmed on an engine that is not in the table, and on a libSQL connection pointed at a Turso URL rather than a local file, which `sqlite3` cannot reach. For those, use a SQL export from [Import & Export](/features/import-export). @@ -138,3 +140,5 @@ A non-zero exit shows the last 64 KB of the tool's stderr in a scrollable monosp | An authentication failure | The password goes through the environment or a config file rather than a prompt, so this is the account or the database. Check that the role can log in | | Objects that conflict with the dump | Restore into a fresh database, or drop the conflicting objects first | | *"a single transaction can only write to a single attached database"* | A DuckDB query tab has an open transaction that has already written. Commit or roll it back, then run the backup again | + +One non-zero exit is not a failure. A `pg_restore` newer than the server sets parameters the server does not have, such as `transaction_timeout` on anything before PostgreSQL 17, and exits 1 after restoring everything. When those are its only errors, the restore reports complete and names the settings the server skipped. Any other error in the same run still fails it. So does a server whose `lc_messages` is not English, because its errors cannot be read. diff --git a/docs/features/change-tracking.mdx b/docs/features/change-tracking.mdx index 3dcc3b07d0..3efb7c2ec0 100644 --- a/docs/features/change-tracking.mdx +++ b/docs/features/change-tracking.mdx @@ -9,6 +9,8 @@ import StagedUntilSave from "/snippets/staged-until-save.mdx"; Modified cells stay highlighted, `Cmd+Shift+P` shows the statements the queue will produce, and `Cmd+S` runs them. The queue belongs to the tab, so switching tabs leaves it alone. +A queued change also marks the value itself, so it stays legible once the row is selected and reads aloud to VoiceOver. An edited value and a new row are underlined, and a row queued for deletion is struck through. + Pending edits, inserts, and deletions highlighted in the data grid Pending edits, inserts, and deletions highlighted in the data grid diff --git a/docs/features/copy-objects.mdx b/docs/features/copy-objects.mdx index 3663de3e42..bc7b4b8e30 100644 --- a/docs/features/copy-objects.mdx +++ b/docs/features/copy-objects.mdx @@ -92,7 +92,9 @@ reshaped where the source and the target spell a value differently. `TINYINT(1)` `TINYINT(1)` as `1`. Nothing is dropped for want of a type. A type the target does not have becomes its widest text -column, so the values arrive as text rather than the column going missing. +column, so the values arrive as text rather than the column going missing. The target server's +version counts too: a JSON column crossing into PostgreSQL arrives as `jsonb` from 9.4, `json` on 9.2 +and 9.3, and `text` on 9.1. The review step lists every column and index the crossing changed, one line each. It is the only place those conversions appear, and it is shown before **Copy** does anything. @@ -195,6 +197,10 @@ into it is treated as one engine and passes its types through, so a `jsonb`, `uu is refused by the server with its own error. Change those columns to `varchar` on the source, or create the target table first and use **Add rows to it**. +A copy from PostgreSQL into an older PostgreSQL passes its types through the same way, so a `jsonb` +column is refused by a server before 9.4. Create the target table first with `json` or `text`, then +use **Add rows to it**. + Duplicate Database needs a driver that creates one. Where it does not, the command still appears and the sheet names the engine that cannot rather than failing part way in. diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index dd8110ceae..945c365fdd 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -1,9 +1,9 @@ --- title: Data Grid -description: Sort, size, hide, chart and copy the rows a table or a query puts in the grid +description: Sort, size, hide, highlight, chart and copy the rows a table or a query puts in the grid --- -Most of the grid's controls sit in the status bar beneath it. The view switcher is at the leading edge, the row count in the middle, then the columns, filter and page buttons at the trailing edge. +Most of the grid's controls sit in the status bar beneath it. The view switcher is at the leading edge, the row count in the middle, then the columns, highlight, filter and page buttons at the trailing edge. Data grid @@ -38,7 +38,24 @@ The header menu carries two filters that answer different questions. Value filter popover -A value filter lives as long as the result does: switching view mode or tab keeps it, replacing the result clears it. **Fetch All** keeps it and applies it to the rows it loads. +A value filter lives as long as the result does: switching view mode or tab keeps it, replacing the result clears it. **Fetch All** keeps it and applies it to the rows it loads. Edit a cell so its row no longer matches and the row stays where it is. The filter runs again when rows arrive or leave, and when you change the filter or the column's display format. + +## Highlighting + +Right-click a cell and open **Highlight**. A color under **Rows Where status = “paid”** tints every row holding that value; a color under **Cells Where status = “paid”** tints only that cell. The palette marks the color a matching rule already uses, and **Remove Highlight** takes the rule away. + +For anything other than an exact match, click the highlighter button in the status bar or choose **View > Highlight Rules**. A rule is a column, an operator from the [filter bar](/features/filtering), a value, a color, and **Row** or **Cell**. **Highlight Values…** in the header menu starts a rule on that column. A rule left without a value is dropped when the popover closes. + + + Highlight Rules popover listing three rules over an Invoice grid with tinted rows and cells + Highlight Rules popover listing three rules over an Invoice grid with tinted rows and cells + + +Rules run top to bottom and the first match colors the row, so drag the rule that should win to the top. A rule picked from the cell menu goes in first. A cell rule tints its own cell over the row's color. Rules read the stored value rather than the text a **Display As** format shows: a numeric column compares as numbers, a boolean column accepts `true`, `1`, `t` and `yes` alike, and `NULL` never satisfies a comparison: match it with **is NULL** or **is empty**. + +A row waiting to be inserted or deleted keeps its [change tracking](/features/change-tracking) tint over any rule, and a selected row shows the selection. On a table you edit, give row rules a color other than the green and red those tints use, so a highlight never reads as a pending change. + +Rules belong to the table, scoped to the connection, database, and schema, and follow the table through a rename. A query result that comes from one table uses that table's rules. Rules on any other query result are not saved, and go when the tab closes or the app quits. Saved rules stay on this Mac and do not sync. ## Columns diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx index 0bfb69afd4..399a430beb 100644 --- a/docs/features/import-export.mdx +++ b/docs/features/import-export.mdx @@ -121,6 +121,8 @@ As SQL, a results export writes `INSERT` statements only. A result set is the ou Structure carries the table's indexes. They are written after the rows, next to the deferred foreign keys, which is where `pg_dump` and `sqlite3 .dump` put them: a bulk load into an indexed table pays to maintain an index the restore is about to build anyway. On MySQL, MariaDB, ClickHouse, Trino and CockroachDB the server writes them inside `CREATE TABLE`, so they arrive with the table instead. A materialized view's indexes follow the view. + On PostgreSQL, Structure carries comments as well. Each table, view and materialized view gets its own `COMMENT ON` and one `COMMENT ON COLUMN` per commented column, directly after its `CREATE`. A foreign table is the exception: its comments are dropped, because the dump writes `CREATE TABLE` for it and PostgreSQL then refuses `COMMENT ON FOREIGN TABLE`. Where the server's own `CREATE TABLE` already carries the comment, MySQL and MariaDB among them, it arrives with the table instead. + All three are on by default, so an SQL export carries `DROP TABLE IF EXISTS` unless you untick **Drop**. Run that file against the wrong database and it drops the tables first. @@ -133,6 +135,16 @@ As SQL, a results export writes `INSERT` statements only. A result set is the ou Splitting writes `dump.part1.sql`, `dump.part2.sql` and so on, rotating between statements so no part ends mid-`INSERT`. Restore the parts in order. A gzipped export is one file, so the two settings do not combine and the summary says so. + A dump is UTF-8 and says so in every part, the way the engine's own dump tool does: + + | Engine | Each part opens with | + |---|---| + | MySQL, MariaDB | `SET NAMES utf8mb4`, and ends by putting the session's character set back | + | PostgreSQL, CockroachDB, PGlite | `SET client_encoding = 'UTF8';` | + | Amazon Redshift | Nothing. Set `PGCLIENTENCODING=UTF8` before restoring it into a database that is not UTF-8 | + + `mysql < dump.sql` and `psql < dump.sql` then restore Japanese, emoji and other non-Latin text intact, even into a `LATIN1` or `EUC_JP` database or from a client whose default is Latin 1. + One snapshot opens `START TRANSACTION WITH CONSISTENT SNAPSHOT` on MySQL, `BEGIN ISOLATION LEVEL REPEATABLE READ` on PostgreSQL, and a deferred transaction on SQLite. It holds that transaction open for the whole export. Excluding the counter drops `AUTO_INCREMENT=` from the table options and leaves the column's own `AUTO_INCREMENT` attribute alone. Restoring rows sets the counter one past the highest key in the data, so a source counter that had run ahead of its rows, after deletes or a reset, does not carry over. @@ -260,7 +272,7 @@ Select a row in the data grid and press `Cmd+V` to paste tabular data straight i | Option | What it does | Default | |--------|-------------|---------| | On error | Stop and Rollback, Stop and Commit, or Skip and Continue | Stop and Rollback | -| Encoding | UTF-8, UTF-16, Latin1, or ASCII | UTF-8 | +| Encoding | UTF-8, UTF-16, UTF-16 LE, UTF-16 BE, Latin-1, Windows-1252, or ASCII | UTF-8 | | Wrap in transaction | Runs every statement inside one transaction. Dimmed in Skip and Continue | Yes | | Disable foreign key checks | Suspends constraint checks for the import | Yes | @@ -270,6 +282,8 @@ Select a row in the data grid and press `Cmd+V` to paste tabular data straight i | **Stop and Commit** | Stops there, keeping what already succeeded | | **Skip and Continue** | Logs it and carries on, including a line the parser cannot read. No transaction | +**UTF-16** reads the byte order from the mark at the start of the file and falls back to big-endian, which is what a file with no mark means. Pick **UTF-16 LE** or **UTF-16 BE** for a file that has no mark and is not big-endian. Latin-1 and Windows-1252 differ over the bytes `0x80` to `0x9F`: a dump written by MySQL keeps its curly quotes, en dashes and euro sign there, so Windows-1252 is the one to pick for it. + Skip and Continue collects up to 1,000 failures with their line numbers and messages, and the summary counts successes against failures behind a **Copy Details** button. **Save Report…** writes them all to a CSV with a line, a statement and the database's own error per row, so a large import's failures can be sorted and searched rather than scrolled. A stop shows the line, the database's own message, and the failing statement, with the dialog still open behind it, ready for a changed setting and another run. ### Disabling foreign key checks @@ -294,7 +308,7 @@ The sheet accepts an array of objects `[{…}, {…}]`, newline-delimited JSON s The proposed name drops the extension, turns spaces and punctuation into underscores, and lowercases the result. Letters from any script are kept as they are. On Oracle the name comes through in upper case instead, and is cut to 30 bytes rather than 63. A name an existing table or view already holds gains a numeric suffix, so re-importing `users.csv` next to a `users` table proposes `users_2`. Whatever you type over it is held to the same rules: a name already taken, one longer than the engine allows, or one starting with a prefix the engine keeps for itself is reported in the sheet, with **Import** off until it changes. -Rows insert through parameterized statements, so a JSON value is never concatenated into SQL. Nested objects and arrays are stored as JSON text. +Rows insert through parameterized statements, so a JSON value is never concatenated into SQL. Nested objects and arrays are stored as JSON text. In a new PostgreSQL table their column is `jsonb`, or `json` on 9.2 and 9.3, and `text` on 9.1, which has no JSON type. ### Import XLSX diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 185199f83e..415084957c 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -212,7 +212,7 @@ First time here: [Quick Start](/quickstart) gets you from install to a query wit Every tab, its controls, and their defaults. - Themes, fonts, colors, connection colors. + Themes, theme colors, connection colors. Editor toggles, Vim mode, indentation. diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 1f7d83de40..31b55746a8 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -18,7 +18,13 @@ Six levels, one per connection, set in the **Customization** pane of its edit fo New connections start at **Silent**, which is the right choice for a local database you own. Move a shared staging connection to **Alert** and anything with real customer data in it to **Safe Mode** or **Read-Only**. -Four things the table cannot carry. The confirmation dialog previews the SQL it is about to run. Touch ID falls back to your macOS password on a Mac without it. **Silent** is not a free pass: `DROP`, `TRUNCATE`, and a `DELETE` with no `WHERE` still raise the built-in dangerous query warning even there. And **Read-Only** goes past queries to the interface itself, disabling inline cell editing, adding, deleting and duplicating rows, table truncate and drop, and import. +Four things the table cannot carry. The confirmation dialog shows the whole statement, syntax highlighted and scrollable, with a **Copy All** button and a line naming the connection and whatever asked to run it. Touch ID falls back to your macOS password on a Mac without it. + + + Confirmation dialog over a query tab, showing a fourteen line UPDATE with Cancel and Execute + Confirmation dialog over a query tab, showing a fourteen line UPDATE with Cancel and Execute + + **Silent** is not a free pass: `DROP`, `TRUNCATE`, and a `DELETE` with no `WHERE` still raise the built-in dangerous query warning even there. And **Read-Only** goes past queries to the interface itself, disabling inline cell editing, adding, deleting and duplicating rows, table truncate and drop, and import. ## Connections that are always read-only @@ -100,4 +106,6 @@ A write from an external client (Raycast, Cursor, Claude Desktop, any MCP client 2. **Token scope** (per integration: `readOnly` / `readWrite` / `fullAccess`), issued by the [pairing flow](/external-api/pairing). The effective permission is `MIN(token.scope, connection.externalAccess)`. 3. **Safe Mode**, per query. Everything on this page then applies, Touch ID prompts and confirmation dialogs included. -`DROP` and `TRUNCATE` always go through the `confirm_destructive_operation` tool, which needs a `fullAccess` token and the user's approval every time: through the client's own elicitation prompt where it has one, otherwise through TablePro's dialog. No token scope skips it. See the [External API security model](/external-api/index#security-model). +`DROP` and `TRUNCATE` always go through the `confirm_destructive_operation` tool, which needs a `fullAccess` token and the user's approval every time. No token scope skips it. See the [External API security model](/external-api/index#security-model). + +A client that answers its own approval prompt has only answered its own. Only a `fullAccess` token stands in for TablePro's dialog. On every other token the dialog still appears on the Mac, so a `readWrite` client asks twice. diff --git a/docs/features/server-dashboard.mdx b/docs/features/server-dashboard.mdx index c21ef8d762..93b975a73a 100644 --- a/docs/features/server-dashboard.mdx +++ b/docs/features/server-dashboard.mdx @@ -34,6 +34,8 @@ Every connection to the server except this one, sortable, one row each. | Duration | How long the current operation has been running | | Query | The statement, truncated at 1,000 characters. Hover for the full text | +On PostgreSQL 9.1 an idle session shows an empty Query: that server keeps only the running statement, not the last one. + Two buttons sit on each row. **Cancel Query** stops the running statement and leaves the connection up: `pg_cancel_backend` on PostgreSQL, `KILL QUERY` on MySQL. **Terminate Session** ends the connection itself with `pg_terminate_backend` or `KILL`, and the server rolls back any transaction that session had open. Both confirm first, and a refusal from the server comes back as an **Action Failed** alert. ## Slow Queries @@ -50,7 +52,8 @@ The interval menu offers 1s, 2s, 5s (the default), 10s, 30s, and Off. **Pause** | Database | Sessions | Metrics | Slow queries | Cancel | Terminate | |----------|:--------:|---------|:------------:|:------:|:---------:| -| PostgreSQL / Redshift / CockroachDB | Yes | Connections, cache hit ratio, database size, uptime, active queries | Yes | Yes | Yes | +| PostgreSQL / Redshift | Yes | Connections, cache hit ratio, database size, uptime, active queries | Yes | Yes | Yes | +| CockroachDB | Yes | Connections, active queries | Yes | Yes | Yes | | MySQL / MariaDB | Yes | Connected threads, running threads, uptime, total queries, slow queries, max connections, bytes received, bytes sent | Yes | Yes | Yes | | SQL Server | Yes | User sessions, uptime, database size | Yes | - | Yes | | ClickHouse | Yes | Active queries, active merges, part mutations, disk usage | Yes | - | Yes | diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 7163fe9d78..8f1889fa6a 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -191,6 +191,6 @@ For the execution plan rather than an opinion, press `Cmd+Option+E` and see [Exp ## Editor settings -**Settings > Editor** holds line numbers, current-line and current-statement highlighting, word wrap, [code folding](/features/code-folding), the per-statement run button, [invisible characters](#invisible-characters), tab width, auto-uppercase keywords, [query parameters](/features/query-parameters), and [vim mode](/features/vim-mode). Editor font family and size are per theme, in **Settings > Appearance**. +**Settings > Editor** holds line numbers, current-line and current-statement highlighting, word wrap, [code folding](/features/code-folding), the per-statement run button, [invisible characters](#invisible-characters), tab width, auto-uppercase keywords, [query parameters](/features/query-parameters), and [vim mode](/features/vim-mode). Editor font family and size are on that same tab. -Editor windows remember their size, position and font zoom between launches. See [Query Tabs](/features/tabs#switching-tabs). +The font size set by `Cmd+=` and `Cmd+-` is the Editor Font setting, so it is kept between launches. See [Query Tabs](/features/tabs#switching-tabs). diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index 89dd4de7cf..f57aad7593 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -68,7 +68,9 @@ No other engine has a rename, so the item never appears on Cassandra, DynamoDB, ## Maintenance -Right-click a table, choose **Maintenance**, and pick an operation. A sheet shows the operation's options and the exact SQL before it runs. +Right-click a table, choose **Maintenance**, and pick an operation. The sheet shows the operation's options and the statement the driver will run, rebuilt as each option changes, so what is previewed is what executes. + +Each operation lists the object kinds it applies to, so a view offers only the operations its server accepts. On PostgreSQL that means VACUUM and REINDEX are absent on a view, which the server skips or refuses, and CLUSTER is absent on a partitioned table, which can never be clustered. An operation that acts on the whole database, such as SQLite's VACUUM and its integrity check, names the database rather than a table. | Database | Operations | |----------|-----------| @@ -76,7 +78,7 @@ Right-click a table, choose **Maintenance**, and pick an operation. A sheet show | MySQL / MariaDB | OPTIMIZE TABLE, ANALYZE TABLE, CHECK TABLE, REPAIR TABLE | | SQLite | VACUUM, ANALYZE, REINDEX, Integrity Check | -PostgreSQL VACUUM carries FULL (rewrites the table and blocks access), ANALYZE, and VERBOSE toggles. MySQL CHECK TABLE offers QUICK, FAST, MEDIUM (the default), EXTENDED, and CHANGED. No other engine reports maintenance operations, so the submenu is hidden there, and in read-only safe mode. +PostgreSQL VACUUM carries FULL (rewrites the table and blocks access), ANALYZE, and VERBOSE toggles. MySQL CHECK TABLE offers QUICK, FAST, MEDIUM (the default), EXTENDED, and CHANGED. The options come from the driver, so an engine that adds one shows it without an app change. No other engine reports maintenance operations, so the submenu is hidden there, and in read-only safe mode. ## Views @@ -84,6 +86,12 @@ Views carry an eye icon in the sidebar; materialized views and foreign tables ge Right-click empty space and choose **New View…** to write a new one; right-click an existing view and choose **Edit View Definition** to open the current definition in a query tab. Either way, execute the statement to apply it. +**Show DDL** opens the definition in a read-only tab, with **Copy**, **Export** and **Open in Editor** on it; **Copy DDL** puts the same statement straight on the clipboard. Both stay available on a read-only connection, where **Edit View Definition** is hidden. + +A materialized view holds stored rows, so refreshing it is a server operation. Right-click one and choose **Refresh Materialized View…**, or use **Database > Refresh Materialized View…**. The confirmation names the view and warns that other sessions cannot read it while the refresh runs. **Refresh concurrently** leaves readers alone; it is available on PostgreSQL for a view that holds rows and carries a unique index on plain columns with no `WHERE` clause, and the alert says which of the two is missing when it is dimmed. Tabs showing the view reload when the refresh finishes. A plain view stores nothing, so `Cmd+R` re-runs its query instead. + +Comments are edited per object. Right-click a table, view, materialized view or foreign table, choose **Edit Comment…**, and type the text; saving an empty field removes the comment. Column comments live in the [Columns tab](/features/table-structure#columns-tab). Both need an engine that writes comments, which today means PostgreSQL and PGlite. + ## Databases and schemas `Cmd+K` switches database, covered in [Managing Connections](/connections#switch-connections-and-databases). The switcher takes the engine's own vocabulary: Schema on Oracle and Spanner, Keyspace on Cassandra, Dataset on BigQuery, Namespace on SurrealDB. SQLite is file-based, so it points at the welcome window to open a different file. diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 8aed9204f1..ee3c0097bd 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -16,6 +16,24 @@ Open a table and switch the result view to **Structure**, or right-click it in t The tabs are **Columns**, **Indexes**, **Foreign Keys**, **Constraints**, **Triggers**, **DDL**, and **Parts** (ClickHouse only); the first five carry item counts. A tab the engine has no concept of is hidden: ClickHouse has no Foreign Keys, Redshift no Triggers, Redis no Constraints. Every grid has a filter field, and clicking a header sorts. +## What each object accepts + +A view is not a table, and the grid offers only the edits the server will take on the object open in front of it. A refused **+** or **-** stays on screen, dimmed, with the reason in its tooltip; a cell the object will not let you change beeps instead of opening an editor. + +PostgreSQL answers this per operation, so the tab does too: + +| Object | What it accepts | +|--------|-----------------| +| Table, partitioned table | Everything on this page | +| View | Rename a column, set or clear its default, edit its comment | +| Materialized view | Rename a column, edit its comment, add and drop indexes | +| Foreign table | Every column change and check constraints. No indexes, no keys | +| System table, external table | Nothing | + +A view and a materialized view differ in both directions: `ALTER TABLE … SET DEFAULT` runs on a view and is refused on a materialized view, and `CREATE INDEX` is the other way round. + +Every other engine edits a table and a partitioned table only. A view, a materialized view, a foreign table and a system table are read-only there. + ## Columns tab Columns are edited in place. **Nullable**, **Primary Key**, and **Auto Inc** are YES/NO dropdowns; **Primary Key** set to YES forces **Nullable** to NO and holds it there until the key comes back off. **Type** opens a picker of the engine's types by category: search to filter, or type a parametric value such as `VARCHAR(255)` and press Return to use it as written. On PostgreSQL and PGlite the picker opens with a **User-Defined** group of the database's enums, composites, domains and ranges, each listed schema-qualified as `sales.status`. See [User-Defined Types](/features/user-defined-types). @@ -47,7 +65,7 @@ Changing the default of a column that already exists takes a different route per ### Generated columns -PostgreSQL, CockroachDB, PGlite, MySQL, MariaDB and SQLite add two more fields. **Generated** is a menu of Not generated, STORED and VIRTUAL; **Expression** holds the SQL the value is computed from. Set both on a new row and the column is created as `GENERATED ALWAYS AS (expression)`. +PostgreSQL, CockroachDB, PGlite, MySQL, MariaDB and SQLite add two more fields. **Generated** is a menu of Not generated, STORED and VIRTUAL; **Expression** holds the SQL the value is computed from. Set both on a new row and the column is created as `GENERATED ALWAYS AS (expression)`. PostgreSQL 11 and earlier have no generated columns, so on those servers neither field appears. The keyword is always written out. PostgreSQL 17 and earlier accept STORED only, PostgreSQL 18 made VIRTUAL the default, and MySQL and MariaDB default to VIRTUAL, so leaving it implicit would mean different columns on different servers. @@ -67,7 +85,7 @@ Add a column with **+** at the right of the status bar or `Cmd+Shift+N`. Select Flag **Primary Key** on one column, or several for a composite key in one `PRIMARY KEY (col1, col2)` clause. On an existing table that becomes a drop of the old constraint followed by an add. -Drag a column row to reorder it, or right-click one and choose **Move Column Up** or **Move Column Down**. Both are dimmed with the reason spelled out under them while unsaved changes exist, while the list is filtered or sorted, on a view, and on an engine that cannot change column order. +Drag a column row to reorder it, or right-click one and choose **Move Column Up** or **Move Column Down**. Both are dimmed with the reason spelled out under them while unsaved changes exist, while the list is filtered or sorted, on anything but a table, and on an engine that cannot change column order. What the drag does depends on the engine: @@ -85,7 +103,7 @@ Everything that runs goes to query history rather than the change queue. | Field | Description | |-------|-------------| | **Columns** | Indexed columns. Multi-column indexes list several columns in one row. MySQL prefix lengths are written as `email(20)` | -| **Type** | BTREE, HASH, FULLTEXT, SPATIAL (MySQL), GIN, GIST, BRIN (PostgreSQL) | +| **Type** | BTREE, HASH, FULLTEXT, SPATIAL (MySQL), GIN, GIST, BRIN (PostgreSQL 9.5 and later) | | **Unique** | Whether the index enforces uniqueness | | **Condition** | `WHERE` predicate for partial indexes (PostgreSQL, SQLite, libSQL, Cloudflare D1) | @@ -100,7 +118,7 @@ Everything that runs goes to query history rather than the change queue. Right-click a foreign key and choose **Open [table]** to jump to the referenced table. Right-click any row in these three grids for **Copy** (the cell under the pointer), **Copy Name**, **Copy Definition**, **Copy As** (CSV, JSON, SQL), **Duplicate**, and **Delete** (`Delete`). A row already marked for deletion offers **Undo Delete**. The same menu appears whether or not the row was already selected, and its row commands act on the whole selection. -**Add Foreign Key** and **Remove Foreign Key** sit under the list. Both are dimmed on a view and on an engine that cannot edit foreign keys, with the reason in the tooltip. +**Add Foreign Key** and **Remove Foreign Key** sit under the list. Both are dimmed on an object that cannot carry a key and on an engine that cannot edit foreign keys, with the reason in the tooltip. What the save does depends on the engine: @@ -169,7 +187,9 @@ Triggers are available for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracl ## DDL tab -Read-only `CREATE TABLE` with syntax highlighting and font size controls. **Copy**, **Export** as a `.sql` file, and **Open in Editor** send it onward. On PostgreSQL the `CREATE SEQUENCE` and `CREATE TYPE … AS ENUM` statements the table depends on are prepended, so the script runs on an empty database. +Read-only `CREATE TABLE` with syntax highlighting and font size controls. **Copy**, **Export** as a `.sql` file, and **Open in Editor** send it onward. On PostgreSQL the `CREATE SEQUENCE` and `CREATE TYPE … AS ENUM` statements the table depends on are prepended, so the script runs on an empty database, and the table's `COMMENT ON` statements follow it. + +Open the tab on a view and it carries that view's own `CREATE VIEW` or `CREATE MATERIALIZED VIEW` instead, the same statement **Show DDL** opens. A materialized view's indexes follow it. ## Parts tab (ClickHouse) diff --git a/docs/images/highlight-rules-dark.png b/docs/images/highlight-rules-dark.png new file mode 100644 index 0000000000..7f5c21fc9b Binary files /dev/null and b/docs/images/highlight-rules-dark.png differ diff --git a/docs/images/highlight-rules.png b/docs/images/highlight-rules.png new file mode 100644 index 0000000000..21febb50ad Binary files /dev/null and b/docs/images/highlight-rules.png differ diff --git a/docs/images/safe-mode-confirmation-dark.png b/docs/images/safe-mode-confirmation-dark.png new file mode 100644 index 0000000000..35ed401406 Binary files /dev/null and b/docs/images/safe-mode-confirmation-dark.png differ diff --git a/docs/images/safe-mode-confirmation.png b/docs/images/safe-mode-confirmation.png new file mode 100644 index 0000000000..7f30a8ae34 Binary files /dev/null and b/docs/images/safe-mode-confirmation.png differ diff --git a/docs/index.mdx b/docs/index.mdx index 4f67682ea0..5bdec4c0bc 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,6 +1,6 @@ --- title: Introduction -description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 29 more +description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 31 more --- import DriverCounts from "/snippets/driver-counts.mdx"; diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 042e8a94bc..8bd71a27c0 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -1,6 +1,6 @@ --- title: iPhone and iPad -description: Browse tables, run queries, and edit rows on nine database engines from a synced connection list +description: Browse tables, run queries, and edit rows on ten database engines from a synced connection list --- You need iOS 18 or later. Builds ship on their own schedule, so a feature in a Mac release note may not be on your phone yet. Your version and build are under **Settings > About**. Connections saved on a Mac come over iCloud; the rest of your Mac setup stays on the Mac. @@ -12,12 +12,13 @@ You need iOS 18 or later. Builds ship on their own schedule, so a feature in a M ## Supported databases -Every driver is compiled in, so there is no plugin system and nothing to install. The type picker offers these nine: +Every driver is compiled in, so there is no plugin system and nothing to install. The type picker offers these ten: | Type | Notes | | --- | --- | | **MySQL** / **MariaDB** | Port 3306 by default | | **TiDB** | Port 4000 by default. Sequences are not listed as tables | +| **OceanBase** | Port 2881 by default. Sign in as `user@tenant` | | **PostgreSQL** | Port 5432 by default | | **SQL Server** | TDS, including Microsoft Entra ID sign-in on a connection synced from the Mac | | **Oracle** | Service name or SID | diff --git a/docs/snippets/driver-counts.mdx b/docs/snippets/driver-counts.mdx index 36442e09d9..d819462ae3 100644 --- a/docs/snippets/driver-counts.mdx +++ b/docs/snippets/driver-counts.mdx @@ -1,3 +1,3 @@ -Five drivers ship inside the app and cover eleven databases. Twenty-one registry plugins cover the -other twenty-three and install on the first connection that needs one. See +Five drivers ship inside the app and cover twelve databases. Twenty-two registry plugins cover the +other twenty-four and install on the first connection that needs one. See [Plugins & Themes](/features/plugins). diff --git a/project.yml b/project.yml index c8a381d0bc..7dbf4c81d8 100644 --- a/project.yml +++ b/project.yml @@ -403,6 +403,8 @@ targets: - Plugins/ClickHouseDriverPlugin/ClickHouseCapabilities.swift - Plugins/ClickHouseDriverPlugin/ClickHouseCredentials.swift - Plugins/ClickHouseDriverPlugin/ClickHouseGeneratedColumnClassification.swift + - Plugins/ClickHouseDriverPlugin/ClickHouseHTTPChunks.swift + - Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift - Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift - Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift - Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift @@ -476,11 +478,20 @@ targets: - Plugins/MongoDBDriverPlugin/MongoScriptText.swift - Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift - Plugins/OracleDriverPlugin/OracleObjectQueries.swift + - Plugins/MySQLDriverPlugin/GeometryWKBParser.swift + - Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift + - Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift + - Plugins/MySQLDriverPlugin/MySQLCharacterSet.swift + - Plugins/MySQLDriverPlugin/MySQLColumnDecoding.swift + - Plugins/MySQLDriverPlugin/MySQLConnectionEncoding.swift + - Plugins/MySQLDriverPlugin/MySQLLatin1.swift - Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift - Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift - Plugins/MySQLDriverPlugin/MySQLIdleRelease.swift - Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift - Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift + - Plugins/MySQLDriverPlugin/MySQLKillTarget.swift + - Plugins/MySQLDriverPlugin/MySQLMaintenance.swift - Plugins/MySQLDriverPlugin/MySQLObjectQueries.swift - Plugins/MySQLDriverPlugin/MySQLSelectLimitStatement.swift - Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -494,25 +505,46 @@ targets: - Plugins/SQLiteDriverPlugin/SQLiteCheckConstraintParser.swift - Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift - Plugins/SQLiteDriverPlugin/SQLiteDefaultValue.swift + - Plugins/SQLiteDriverPlugin/SQLiteMaintenance.swift - Plugins/LibSQLDriverPlugin/LibSQLDefaultValue.swift - Plugins/LibSQLDriverPlugin/HranaHttpClient.swift - Plugins/CloudflareD1DriverPlugin/CloudflareD1DefaultValue.swift - Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift - Plugins/MSSQLDriverPlugin/MSSQLCheckConstraintDefinition.swift + - Plugins/PostgreSQLDriverPlugin/CockroachRelationSQL.swift - Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift - Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift + - Plugins/PostgreSQLDriverPlugin/LibPQCellDecoding.swift + - Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift + - Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift + - Plugins/PostgreSQLDriverPlugin/LibPQCopyDirection+UnsupportedMessage.swift + - Plugins/PostgreSQLDriverPlugin/LibPQPendingResultDrain.swift + - Plugins/PostgreSQLDriverPlugin/LibPQPluginError.swift - Plugins/PostgreSQLDriverPlugin/LibPQSSLMapping.swift + - Plugins/PostgreSQLDriverPlugin/LibPQStringConformance.swift - Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogForeignKeys.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogTypeNames.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLCheckConstraintDefinition.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLCommentStatements.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLForeignKeyQueries.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLMaintenance.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLRelationSQL.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLViewDefinition.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLSequenceQueries.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLSystemDatabases.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLVersionedStatements.swift - Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift - Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift - Plugins/KafkaDriverPlugin/KafkaApiKey.swift @@ -563,6 +595,7 @@ targets: - Plugins/RedisDriverPlugin/RedisStatementGenerator.swift - Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift - Plugins/SQLExportPlugin/SQLExportDDLRewriter.swift + - Plugins/SQLExportPlugin/SQLExportEncodingDeclaration.swift - Plugins/SQLExportPlugin/SQLExportFileWriter.swift - Plugins/SQLExportPlugin/SQLExportInsertMode.swift - Plugins/SQLExportPlugin/SQLExportModels.swift @@ -609,7 +642,7 @@ targets: dependencies: - target: TablePro - package: TableProCore - products: [TableProMSSQLCore, TableProNumberFormatting] + products: [TableProMSSQLCore, TableProNumberFormatting, TableProWeaviateCore] # The Kafka integration suite drives the real driver, so the test target links what # the plugin target links: zstd for decompression and NIO for the transport. - package: zstd @@ -1232,6 +1265,15 @@ targets: - package: TableProCore product: TableProNumberFormatting + WeaviateDriverPlugin: + templates: [DriverPlugin] + templateAttributes: + folder: WeaviateDriverPlugin + principalClass: WeaviatePlugin + dependencies: + - package: TableProCore + product: TableProWeaviateCore + # Compile-checks every plugin, including the registry-only ones the app does not # embed. CI builds this scheme so a registry plugin cannot rot between releases. aggregateTargets: @@ -1276,6 +1318,7 @@ aggregateTargets: - TeradataDriver - TrinoDriverPlugin - TypesenseDriverPlugin + - WeaviateDriverPlugin - XLSXExport scheme: {} diff --git a/scripts/check-mysql-charset-decoding.sh b/scripts/check-mysql-charset-decoding.sh new file mode 100755 index 0000000000..3ee16221ad --- /dev/null +++ b/scripts/check-mysql-charset-decoding.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Compare the MySQL driver's character-set decoders against a real server. +# +# MySQLCharacterSet maps a server charset name to a Foundation encoding by hand, and MySQLLatin1 +# carries MySQL's own latin1 table, which is cp1252 with five bytes passed through as C1 +# controls. Both are transcriptions of what the server does, and Foundation's idea of a charset +# disagrees with MySQL's for several names that look identical (latin1, greek, hebrew, sjis). This +# asks the server how it converts every byte of every single-byte charset in the table, and a set +# of sample strings for the multibyte ones, and fails if the Swift decoders disagree. +# +# Usage: +# scripts/check-mysql-charset-decoding.sh [host] [port] [user] +# +# Needs the mysql client, xcrun swiftc, and a MySQL 8 or MariaDB 10.5+ server. The password, if +# any, comes from MYSQL_PWD. A byte the server leaves undefined may decode to a replacement +# character or pass through as its own code point, as MySQL's latin1 does. Exits non-zero on a +# disagreement. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-3306}" +USER_NAME="${3:-root}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLUGIN="$ROOT/Plugins/MySQLDriverPlugin" + +command -v mysql > /dev/null || { + echo "mysql client not found" >&2 + exit 3 +} + +MYSQL=(mysql --no-defaults -h "$HOST" -P "$PORT" -u "$USER_NAME" -N -B -r --default-character-set=utf8mb4) +if ! "${MYSQL[@]}" -e "SELECT 1" > /dev/null 2>&1; then + echo "no MySQL at $HOST:$PORT for $USER_NAME" >&2 + exit 3 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/main.swift" <<'SWIFT' +import Foundation + +func bytes(fromHex hex: Substring) -> [UInt8]? { + guard hex.count % 2 == 0 else { return nil } + var result: [UInt8] = [] + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. String { + input.withUnsafeBytes { MySQLCharacterSet(serverName: name).decode($0) } +} + +let arguments = CommandLine.arguments +if arguments.count == 2, arguments[1] == "names" { + for name in ["latin1"] + MySQLCharacterSet.singleByteDecodedNames { + print("\(name) single") + } + for name in MySQLCharacterSet.multiByteDecodedNames { + print("\(name) multi") + } + exit(0) +} + +var failures = 0 +for line in (try String(contentsOfFile: arguments[1], encoding: .utf8)).split(separator: "\n") { + let fields = line.split(separator: "\t", omittingEmptySubsequences: false) + guard fields.count >= 3 else { continue } + let name = String(fields[0]) + switch fields[1] { + case "byte": + guard let byte = UInt8(fields[2], radix: 16), fields.count == 4, + fields[3] != "NULL", let server = bytes(fromHex: fields[3]) else { continue } + let undefined = server == [0x3F] && byte != 0x3F + let local = decode([byte], name) + let passThrough = String(Unicode.Scalar(byte)) + let acceptable = undefined ? ["\u{FFFD}", "?", passThrough] : [String(decoding: server, as: UTF8.self)] + if !acceptable.contains(local) { + print("\(name) byte \(fields[2]): server \(fields[3]) local \(local.unicodeScalars.map { String($0.value, radix: 16) })") + failures += 1 + } + case "sample": + guard fields.count == 5, let encoded = bytes(fromHex: fields[3]), let back = bytes(fromHex: fields[4]) else { + continue + } + let sample = String(fields[2]) + guard String(decoding: back, as: UTF8.self) == sample else { continue } + let local = decode(encoded, name) + if local != sample { + print("\(name) sample \(sample): local \(local)") + failures += 1 + } + default: + continue + } +} +print(failures == 0 ? "OK" : "\(failures) disagreements") +exit(failures == 0 ? 0 : 1) +SWIFT + +xcrun swiftc -O -o "$WORK/check" "$WORK/main.swift" \ + "$PLUGIN/MySQLCharacterSet.swift" "$PLUGIN/MySQLLatin1.swift" > "$WORK/build.log" 2>&1 || { + cat "$WORK/build.log" >&2 + exit 3 +} + +SAMPLES=("メール・記事紐付け" "~" "〜" "①" "髙" "¥" "\\" "‖" "¬" "㈱" '“' "€" "中文简体" "繁體中文" + "한국어" "Привет" "ґєії" "ąčęėįšųūž" "ğışİ" "łóźżćńśŁ" "àéüß" "😀" "アイウ" "∑" "×" $'\xe2\x80\x94' "£") + +NAMES="$("$WORK/check" names)" && [ -n "$NAMES" ] || { + echo "the decoder listed no character sets" >&2 + exit 3 +} + +: > "$WORK/server.tsv" +: > "$WORK/mysql.err" +while read -r NAME KIND; do + KNOWN="$("${MYSQL[@]}" -e "SELECT COUNT(*) FROM information_schema.CHARACTER_SETS WHERE CHARACTER_SET_NAME = '$NAME'" 2>> "$WORK/mysql.err")" + if [ "$KNOWN" != "1" ]; then + echo "skipped $NAME: this server has no such character set" + continue + fi + if [ "$KIND" = "single" ]; then + COLUMNS="" + for BYTE in $(seq 0 255); do + HEX="$(printf '%02X' "$BYTE")" + COLUMNS="$COLUMNS${COLUMNS:+,}CONCAT('$NAME\tbyte\t$HEX\t', IFNULL(HEX(CONVERT(CONVERT(UNHEX('$HEX') USING $NAME) USING utf8mb4)), 'NULL'))" + done + "${MYSQL[@]}" -e "SELECT $COLUMNS" 2>> "$WORK/mysql.err" | tr '\t' '\n' | paste - - - - >> "$WORK/server.tsv" + fi + for SAMPLE in "${SAMPLES[@]}"; do + LITERAL="${SAMPLE//\\/\\\\}" + "${MYSQL[@]}" -e "SELECT '$NAME', 'sample', _utf8mb4'$LITERAL', HEX(CONVERT(_utf8mb4'$LITERAL' USING $NAME)), HEX(CONVERT(CONVERT(_utf8mb4'$LITERAL' USING $NAME) USING utf8mb4))" \ + >> "$WORK/server.tsv" 2>> "$WORK/mysql.err" + done +done <<< "$NAMES" + +if grep -v -e '^WARNING' -e '^$' "$WORK/mysql.err" > /dev/null; then + grep -v -e '^WARNING' "$WORK/mysql.err" >&2 + exit 3 +fi + +"$WORK/check" "$WORK/server.tsv" diff --git a/scripts/check-postgres-client-encoding.sh b/scripts/check-postgres-client-encoding.sh new file mode 100755 index 0000000000..c812e153c7 --- /dev/null +++ b/scripts/check-postgres-client-encoding.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# +# Check the server facts the PostgreSQL driver and SQL export rely on for text encoding. +# +# The macOS and iOS drivers put client_encoding=UTF8 in the libpq connection string instead of +# running SET client_encoding after connect, because a value from the startup packet is the +# session's reset value and survives RESET ALL and DISCARD ALL, while a SET does not. SQL export +# opens a PostgreSQL-family dump with SET client_encoding = 'UTF8', which only helps if the engine +# accepts that statement. This asks a real server both questions, then restores a UTF-8 dump into +# LATIN1 and EUC_JP databases through psql reading stdin, where psql leaves the client encoding at +# the database's own, and checks the bytes that were stored. +# +# Usage: +# scripts/check-postgres-client-encoding.sh [host] [port] [user] [database] +# +# Needs psql. The password, if any, comes from PGPASSWORD. Point it at CockroachDB, PGlite or any +# other PostgreSQL-compatible engine to check the first two facts there; the restore check is +# skipped on an engine that cannot create and connect to a LATIN1 or EUC_JP database, which +# includes both of those. Exits non-zero on a failure. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-5432}" +USER_NAME="${3:-postgres}" +DATABASE="${4:-postgres}" + +command -v psql > /dev/null || { + echo "psql not found" >&2 + exit 3 +} + +unset PGCLIENTENCODING +BASE="host=$HOST port=$PORT user=$USER_NAME sslmode=prefer connect_timeout=10" +PSQL=(psql -X -A -t -v ON_ERROR_STOP=1) + +if ! "${PSQL[@]}" "$BASE dbname=$DATABASE" -c "SELECT 1" > /dev/null 2>&1; then + echo "no PostgreSQL at $HOST:$PORT for $USER_NAME" >&2 + exit 3 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +failures=0 + +check() { + local label=$1 expected=$2 actual=$3 + if [ "$expected" = "$actual" ]; then + echo "ok $label" + else + echo "FAIL $label: expected '$expected', got '$actual'" + failures=$((failures + 1)) + fi +} + +pinned="$BASE dbname=$DATABASE client_encoding=UTF8" +check "startup client_encoding=UTF8" "UTF8" "$("${PSQL[@]}" "$pinned" -c "SHOW client_encoding" 2>&1)" +check "UTF8 after RESET ALL" "UTF8" "$("${PSQL[@]}" "$pinned" -c "RESET ALL" -c "SHOW client_encoding" 2>&1 | tail -1)" +check "UTF8 after DISCARD ALL" "UTF8" "$("${PSQL[@]}" "$pinned" -c "DISCARD ALL" -c "SHOW client_encoding" 2>&1 | tail -1)" +check "SET client_encoding = 'UTF8' accepted" "SET" \ + "$(psql -X -v ON_ERROR_STOP=1 "$BASE dbname=$DATABASE" -c "SET client_encoding = 'UTF8'" 2>&1)" + +restore_check() { + local encoding=$1 text=$2 expected_hex=$3 db + db="tablepro_encoding_probe_$(echo "$encoding" | tr '[:upper:]' '[:lower:]')" + "${PSQL[@]}" "$BASE dbname=$DATABASE" -c "DROP DATABASE IF EXISTS $db" > /dev/null 2>&1 + if ! "${PSQL[@]}" "$BASE dbname=$DATABASE" \ + -c "CREATE DATABASE $db ENCODING '$encoding' LC_COLLATE 'C' LC_CTYPE 'C' TEMPLATE template0" \ + > /dev/null 2>&1; then + echo "skip $encoding restore: the server cannot create a $encoding database" + return + fi + local reached + reached=$("${PSQL[@]}" "$BASE dbname=$db" -c "SHOW server_encoding" 2>&1) + if [ "$reached" != "$encoding" ]; then + echo "skip $encoding restore: connecting to $db reached a $reached database" + "${PSQL[@]}" "$BASE dbname=$DATABASE" -c "DROP DATABASE IF EXISTS $db" > /dev/null 2>&1 + return + fi + + printf "SET client_encoding = 'UTF8';\n\nCREATE TABLE t (v text);\nINSERT INTO t VALUES ('%s');\n" "$text" \ + > "$WORK/dump.sql" + local output + output=$("${PSQL[@]}" "$BASE dbname=$db" < "$WORK/dump.sql" 2>&1) + local stored + stored=$("${PSQL[@]}" "$BASE dbname=$db" -c "SELECT encode(convert_to(v, '$encoding'), 'hex') FROM t" 2>&1) + check "$encoding restore of a UTF-8 dump through stdin" "$expected_hex" "${stored:-$output}" + + local session + session=$("${PSQL[@]}" "$BASE dbname=$db client_encoding=UTF8" -c "RESET ALL" -c "SELECT v FROM t" 2>&1 | tail -1) + check "$encoding read through a UTF8-pinned session after RESET ALL" "$text" "$session" + + "${PSQL[@]}" "$BASE dbname=$DATABASE" -c "DROP DATABASE IF EXISTS $db" > /dev/null 2>&1 +} + +restore_check LATIN1 "café" "636166e9" +restore_check EUC_JP "メール" "a5e1a1bca5eb" + +if [ "$failures" -gt 0 ]; then + echo "$failures check(s) failed" + exit 1 +fi +echo "all checks passed" diff --git a/scripts/check-postgres-literal-quoting.sh b/scripts/check-postgres-literal-quoting.sh new file mode 100755 index 0000000000..8eb442c027 --- /dev/null +++ b/scripts/check-postgres-literal-quoting.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Check PostgreSQLObjectQueries.quoteLiteral against a real PostgreSQL server. +# +# A catalog name may legally hold a backslash, and with standard_conforming_strings = off a +# backslash inside a plain literal is an escape. So a name listed as '...' can decode to something +# else, or end the literal early and leave the rest as SQL. Neither shows up in a unit test, because +# both depend on a server setting, and neither raises: the first returns no rows and the second +# returns too many. +# +# This builds a schema for every name shape that matters, runs the literal the plugin now emits +# against a session with the setting off, and compares the row count with the one the plugin +# emitted before the fix. +# +# Usage: +# scripts/check-postgres-literal-quoting.sh [host] [port] [user] +# +# Needs psql and a reachable PostgreSQL the user may create a database on. Exits non-zero when a +# quoted literal does not find exactly the one table its schema holds. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-5432}" +USER_NAME="${3:-postgres}" +SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift" +DATABASE="tablepro_literal_quoting_check" + +command -v psql > /dev/null || { + echo "psql not found" >&2 + exit 3 +} +[ -f "$SOURCE" ] || { + echo "not found: $SOURCE" >&2 + exit 3 +} + +psql_do() { + psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$1" -v ON_ERROR_STOP=1 "${@:2}" +} + +if ! psql_do postgres -Atc "SELECT 1" > /dev/null 2>&1; then + echo "no PostgreSQL at $HOST:$PORT as $USER_NAME" >&2 + exit 3 +fi + +grep -qF "E'" "$SOURCE" || { + echo "FAIL: $SOURCE no longer emits an E-string; update this script with the quoting rule" >&2 + exit 1 +} + +VERSION="$(psql_do postgres -Atc "SHOW server_version")" +echo "Checking literal quoting against PostgreSQL $VERSION at $HOST:$PORT" + +psql_do postgres -c "DROP DATABASE IF EXISTS $DATABASE" > /dev/null +psql_do postgres -c "CREATE DATABASE $DATABASE" > /dev/null +trap 'psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP DATABASE IF EXISTS $DATABASE" > /dev/null 2>&1' EXIT + +# Each schema holds exactly one table, so a correct literal always answers 1. +# name | plain literal (the old spelling) | E-string (the spelling the plugin now emits) +CASES=( + "plain|'plain'|'plain'" + "o'q|'o''q'|'o''q'" + "a\\b|'a\\b'|E'a\\\\b'" + "x\\' OR true--|'x\\'' OR true--'|E'x\\\\'' OR true--'" + "tail\\|'tail\\'|E'tail\\\\'" +) + +for entry in "${CASES[@]}"; do + name="${entry%%|*}" + psql_do "$DATABASE" -c "CREATE SCHEMA \"${name//\"/\"\"}\"" > /dev/null + psql_do "$DATABASE" -c "CREATE TABLE \"${name//\"/\"\"}\".t1 (id int)" > /dev/null +done + +# Counting relations across the whole database is what makes an early-closed literal visible: a +# predicate the server parsed as OR true answers with every table rather than raising. +count_for() { + PGOPTIONS="-c standard_conforming_strings=off" psql -X -q -A -t \ + -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$DATABASE" -c " + SELECT count(*) + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'm', 'f')" 2> /dev/null +} + +failures=0 +for entry in "${CASES[@]}"; do + rest="${entry#*|}" + name="${entry%%|*}" + plain="${rest%%|*}" + quoted="${rest#*|}" + + before="$(count_for "$plain")" + after="$(count_for "$quoted")" + [ -n "$before" ] || before="error" + + if [ "$after" = "1" ]; then + printf 'ok %-18s before=%-6s after=%s\n' "$name" "$before" "$after" + else + printf 'FAIL %-18s before=%-6s after=%s (expected 1)\n' "$name" "$before" "$after" + failures=$((failures + 1)) + fi +done + +if [ "$failures" -gt 0 ]; then + echo "$failures literal(s) did not find exactly their own schema; see PostgreSQLObjectQueries.quoteLiteral" >&2 + exit 1 +fi + +echo "Every quoted literal found exactly its own schema on PostgreSQL $VERSION with standard_conforming_strings off." diff --git a/scripts/check-postgres-matview-refresh.sh b/scripts/check-postgres-matview-refresh.sh new file mode 100755 index 0000000000..faf48af890 --- /dev/null +++ b/scripts/check-postgres-matview-refresh.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Check the concurrent-refresh rule against a real PostgreSQL server. +# +# PostgreSQL refuses REFRESH MATERIALIZED VIEW CONCURRENTLY unless the view is populated and has a +# unique index it can diff through, and it says so only when the statement runs. TablePro decides +# whether to offer the option beforehand, from a catalog predicate in PostgreSQLRelationSQL. That +# predicate is a hand-written copy of a server rule, which is the shape that drifts silently: offer +# the option too freely and the refresh fails, too rarely and the option is missing for a view that +# qualifies. +# +# This builds a materialized view for every index shape that matters, asks the predicate, runs the +# real refresh, and compares the two answers. +# +# Usage: +# scripts/check-postgres-matview-refresh.sh [host] [port] [user] +# +# Needs psql and a reachable PostgreSQL 9.4 or newer the user may create a database on. Exits +# non-zero on a disagreement. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-5432}" +USER_NAME="${3:-postgres}" +SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/Plugins/PostgreSQLDriverPlugin/PostgreSQLRelationSQL.swift" +DATABASE="tablepro_matview_refresh_check" + +command -v psql > /dev/null || { + echo "psql not found" >&2 + exit 3 +} +[ -f "$SOURCE" ] || { + echo "not found: $SOURCE" >&2 + exit 3 +} + +psql_do() { + psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$1" -v ON_ERROR_STOP=1 "${@:2}" +} + +if ! psql_do postgres -Atc "SELECT 1" > /dev/null 2>&1; then + echo "no PostgreSQL at $HOST:$PORT as $USER_NAME" >&2 + exit 3 +fi + +VERSION="$(psql_do postgres -Atc "SHOW server_version")" +echo "Checking the concurrent-refresh predicate against PostgreSQL $VERSION at $HOST:$PORT" + +# The predicate as the plugin builds it, read out of the source so the two cannot drift. The Swift +# side interpolates the schema and the name; here they are bound per view instead. +PREDICATE="c.relispopulated AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class ic ON ic.oid = i.indexrelid + JOIN pg_catalog.pg_am am ON am.oid = ic.relam + WHERE i.indrelid = c.oid + AND i.indisunique + AND i.indimmediate + AND i.indisvalid + AND i.indpred IS NULL + AND i.indexprs IS NULL + AND am.amname = 'btree' +)" + +for fragment in "i.indisunique" "i.indimmediate" "i.indisvalid" "i.indpred IS NULL" "i.indexprs IS NULL" "c.relispopulated"; do + grep -qF "$fragment" "$SOURCE" || { + echo "FAIL: $SOURCE no longer tests $fragment; update this script with the predicate" >&2 + exit 1 + } +done + +psql_do postgres -c "DROP DATABASE IF EXISTS $DATABASE" > /dev/null +psql_do postgres -c "CREATE DATABASE $DATABASE" > /dev/null +trap 'psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d postgres -c "DROP DATABASE IF EXISTS $DATABASE" > /dev/null 2>&1' EXIT + +psql_do "$DATABASE" > /dev/null <<'SQL' +CREATE TABLE src (id int NOT NULL, a text, b text); +INSERT INTO src SELECT g, 'a' || g, 'b' || (g % 3) FROM generate_series(1, 20) g; + +CREATE MATERIALIZED VIEW mv_none AS SELECT id, a, b FROM src; + +CREATE MATERIALIZED VIEW mv_unique AS SELECT id, a, b FROM src; +CREATE UNIQUE INDEX mv_unique_id ON mv_unique (id); + +CREATE MATERIALIZED VIEW mv_unique_multi AS SELECT id, a, b FROM src; +CREATE UNIQUE INDEX mv_unique_multi_id ON mv_unique_multi (b, id); + +CREATE MATERIALIZED VIEW mv_unique_include AS SELECT id, a, b FROM src; + +CREATE MATERIALIZED VIEW mv_partial AS SELECT id, a, b FROM src; +CREATE UNIQUE INDEX mv_partial_id ON mv_partial (id) WHERE id > 5; + +CREATE MATERIALIZED VIEW mv_expression AS SELECT id, a, b FROM src; +CREATE UNIQUE INDEX mv_expression_a ON mv_expression (lower(a)); + +CREATE MATERIALIZED VIEW mv_mixed AS SELECT id, a, b FROM src; +CREATE UNIQUE INDEX mv_mixed_id_lower ON mv_mixed (id, lower(a)); + +CREATE MATERIALIZED VIEW mv_not_unique AS SELECT id, a, b FROM src; +CREATE INDEX mv_not_unique_id ON mv_not_unique (id); + +CREATE MATERIALIZED VIEW mv_unpopulated AS SELECT id, a, b FROM src WITH NO DATA; +CREATE UNIQUE INDEX mv_unpopulated_id ON mv_unpopulated (id); +SQL + +# INCLUDE arrived in PostgreSQL 11; where it is missing the view keeps a plain unique index, which +# still belongs in the comparison. +psql_do "$DATABASE" -c "CREATE UNIQUE INDEX mv_unique_include_id ON mv_unique_include (id) INCLUDE (a)" > /dev/null 2>&1 \ + || psql_do "$DATABASE" -c "CREATE UNIQUE INDEX mv_unique_include_id ON mv_unique_include (id)" > /dev/null + +VIEWS="$(psql_do "$DATABASE" -Atc " + SELECT c.relname + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'm' AND n.nspname = 'public' + ORDER BY c.relname")" + +failures=0 +for view in $VIEWS; do + predicted="$(psql_do "$DATABASE" -Atc " + SELECT CASE WHEN $PREDICATE THEN 'yes' ELSE 'no' END + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'm' AND n.nspname = 'public' AND c.relname = '$view'")" + + if psql -X -q -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$DATABASE" -v ON_ERROR_STOP=1 \ + -c "REFRESH MATERIALIZED VIEW CONCURRENTLY public.$view" > /dev/null 2>&1; then + actual="yes" + else + actual="no" + fi + + if [ "$predicted" = "$actual" ]; then + printf 'ok %-22s predicate=%-3s server=%s\n' "$view" "$predicted" "$actual" + else + printf 'FAIL %-22s predicate=%-3s server=%s\n' "$view" "$predicted" "$actual" + failures=$((failures + 1)) + fi +done + +if [ "$failures" -gt 0 ]; then + echo "$failures view(s) disagree with the predicate in PostgreSQLRelationSQL.swift" >&2 + exit 1 +fi + +echo "The predicate agrees with PostgreSQL $VERSION on every index shape." diff --git a/scripts/check-postgres-object-queries.sh b/scripts/check-postgres-object-queries.sh deleted file mode 100755 index f0f2b873d6..0000000000 --- a/scripts/check-postgres-object-queries.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env bash -# -# Run the PostgreSQL routine and trigger catalog queries against a live server and check the -# answers, so a hand-written query cannot drift from what the server actually returns. -# -# The queries in Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift are hand-written and -# nothing at runtime checks them. Three of the things they get right are only visible with real -# overloads in the catalog: -# - one row per routine, not one per pairing of a name with itself -# - a distinct oid per overload, which is what makes the DDL fetch address the right one -# - aggregates excluded, because pg_get_functiondef raises on them and would fail the whole list -# -# Usage: -# scripts/check-postgres-object-queries.sh [database] [schema] -# -# Defaults to the `postgres` database and a scratch schema it creates and drops. - -set -euo pipefail - -DATABASE="${1:-postgres}" -SCHEMA="${2:-tablepro_object_query_check}" - -if ! command -v psql > /dev/null 2>&1; then - echo "psql not found" >&2 - exit 2 -fi - -if ! psql -d "$DATABASE" -Atc 'SELECT 1' > /dev/null 2>&1; then - echo "cannot connect to database '$DATABASE'" >&2 - exit 2 -fi - -cleanup() { - psql -d "$DATABASE" -q -c "DROP SCHEMA IF EXISTS $SCHEMA CASCADE" > /dev/null 2>&1 || true -} -trap cleanup EXIT - -failures=0 - -fail() { - echo "FAIL: $1" >&2 - failures=$((failures + 1)) -} - -psql -d "$DATABASE" -q -v ON_ERROR_STOP=1 > /dev/null << SQL -DROP SCHEMA IF EXISTS $SCHEMA CASCADE; -CREATE SCHEMA $SCHEMA; -CREATE FUNCTION $SCHEMA.transform(a integer) RETURNS integer LANGUAGE sql IMMUTABLE AS \$\$ SELECT \$1 \$\$; -CREATE FUNCTION $SCHEMA.transform(a text) RETURNS integer LANGUAGE sql AS \$\$ SELECT 2 \$\$; -CREATE FUNCTION $SCHEMA.transform(a date, b int) RETURNS integer LANGUAGE sql AS \$\$ SELECT 3 \$\$; -CREATE PROCEDURE $SCHEMA.sync_orders() LANGUAGE plpgsql AS \$\$ BEGIN NULL; END \$\$; -CREATE AGGREGATE $SCHEMA.my_sum(int) (SFUNC = int4pl, STYPE = int); -CREATE TABLE $SCHEMA.orders(id int primary key, total numeric); -CREATE TABLE $SCHEMA.customers(id int primary key); -CREATE FUNCTION $SCHEMA.audit_fn() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RETURN NEW; END \$\$; -CREATE TRIGGER audit BEFORE INSERT OR UPDATE ON $SCHEMA.orders - FOR EACH ROW WHEN (NEW.total > 0) EXECUTE FUNCTION $SCHEMA.audit_fn(); -CREATE TRIGGER audit AFTER DELETE ON $SCHEMA.customers - FOR EACH STATEMENT EXECUTE FUNCTION $SCHEMA.audit_fn(); -SQL - -ROUTINE_LIST=" -SELECT p.oid::text, p.proname, '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')', p.prokind -FROM pg_catalog.pg_proc p -JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace -JOIN pg_catalog.pg_language l ON l.oid = p.prolang -WHERE n.nspname = '$SCHEMA' - AND p.prokind IN ('f', 'p') - AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') -" - -rows=$(psql -d "$DATABASE" -Atc "$ROUTINE_LIST" | wc -l | tr -d ' ') -[ "$rows" = "5" ] || fail "routine list returned $rows rows, expected 5 (3 overloads, 1 procedure, 1 trigger function)" - -transform_rows=$(psql -d "$DATABASE" -Atc "$ROUTINE_LIST AND p.proname = 'transform'" | wc -l | tr -d ' ') -[ "$transform_rows" = "3" ] || fail "three overloads returned $transform_rows rows, expected 3" - -distinct_oids=$(psql -d "$DATABASE" -Atc "SELECT count(DISTINCT p.oid) FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = '$SCHEMA' AND p.proname = 'transform'") -[ "$distinct_oids" = "3" ] || fail "three overloads share $distinct_oids oids, expected 3 distinct" - -distinct_args=$(psql -d "$DATABASE" -Atc "SELECT count(DISTINCT pg_catalog.pg_get_function_identity_arguments(p.oid)) - FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = '$SCHEMA' AND p.proname = 'transform'") -[ "$distinct_args" = "3" ] || fail "three overloads share $distinct_args argument signatures, expected 3 distinct" - -aggregates=$(psql -d "$DATABASE" -Atc "$ROUTINE_LIST AND p.proname = 'my_sum'" | wc -l | tr -d ' ') -[ "$aggregates" = "0" ] || fail "an aggregate reached the routine list; pg_get_functiondef raises on it" - -if psql -d "$DATABASE" -Atc "SELECT pg_catalog.pg_get_functiondef(p.oid) FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = '$SCHEMA' AND p.proname = 'my_sum'" > /dev/null 2>&1; then - echo "note: pg_get_functiondef no longer raises on an aggregate on this server version" -fi - -for oid in $(psql -d "$DATABASE" -Atc "SELECT p.oid FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = '$SCHEMA' AND p.proname = 'transform' ORDER BY p.oid"); do - args=$(psql -d "$DATABASE" -Atc "SELECT pg_catalog.pg_get_function_identity_arguments($oid)") - definition=$(psql -d "$DATABASE" -Atc "SELECT pg_catalog.pg_get_functiondef($oid::oid)" | head -1) - case "$definition" in - *"($args)"*) ;; - *) fail "pg_get_functiondef($oid) returned a definition for a different overload: $definition" ;; - esac -done - -TRIGGER_LIST=" -SELECT t.tgname, c.relname, - CASE WHEN (t.tgtype & 64) != 0 THEN 'INSTEAD OF' - WHEN (t.tgtype & 2) != 0 THEN 'BEFORE' ELSE 'AFTER' END, - array_to_string(array_remove(ARRAY[ - CASE WHEN (t.tgtype & 4) != 0 THEN 'INSERT' END, - CASE WHEN (t.tgtype & 8) != 0 THEN 'DELETE' END, - CASE WHEN (t.tgtype & 16) != 0 THEN 'UPDATE' END, - CASE WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' END], NULL), ' OR '), - CASE WHEN (t.tgtype & 1) != 0 THEN 'ROW' ELSE 'STATEMENT' END -FROM pg_catalog.pg_trigger t -JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid -JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace -WHERE n.nspname = '$SCHEMA' AND NOT t.tgisinternal -ORDER BY c.relname, t.tgname -" - -trigger_rows=$(psql -d "$DATABASE" -Atc "$TRIGGER_LIST") -expected=$'audit|customers|AFTER|DELETE|STATEMENT\naudit|orders|BEFORE|INSERT OR UPDATE|ROW' -if [ "$trigger_rows" != "$expected" ]; then - fail "trigger list disagreed" - echo "expected:" >&2 - echo "$expected" >&2 - echo "got:" >&2 - echo "$trigger_rows" >&2 -fi - -definition=$(psql -d "$DATABASE" -Atc "SELECT pg_catalog.pg_get_triggerdef(t.oid) FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '$SCHEMA' AND c.relname = 'orders' AND t.tgname = 'audit'") -case "$definition" in - *"WHEN"*) ;; - *) fail "pg_get_triggerdef dropped the WHEN clause: $definition" ;; -esac - -if [ "$failures" -gt 0 ]; then - echo "$failures check(s) failed" >&2 - exit 1 -fi - -echo "PostgreSQL object catalog queries agree with $(psql -d "$DATABASE" -Atc 'SHOW server_version')" diff --git a/scripts/check-postgresql-compat.sh b/scripts/check-postgresql-compat.sh new file mode 100755 index 0000000000..77192673a2 --- /dev/null +++ b/scripts/check-postgresql-compat.sh @@ -0,0 +1,634 @@ +#!/bin/bash +# +# Runs the real PostgreSQL driver against one live server and checks what its catalog reads return. +# +# The driver's catalog SQL is hand-written, and a construct a newer server added fails at parse time +# on an older one, even inside a CASE branch that never runs. Nothing at runtime notices until a +# user on that server opens the sidebar. Every read below failed or answered wrongly on some +# PostgreSQL release before it was rewritten (#2734): to_regclass(text) is 9.6, LATERAL is 9.3, +# unnest WITH ORDINALITY and json_build_object are 9.4, to_json and json_agg are 9.3, pg_sequences +# and collprovider are 10, and array_position is 9.5. +# +# Builds TableProPluginKit and every PostgreSQL driver source with swiftc, loads a fixture into a +# scratch database, prints one canonical line per object each read returns, and diffs that against +# the answers below. A second pass connects as a role that owns nothing, because a read that works +# for the owner can still fail for a reader (a sequence the role cannot SELECT, for one). Overloads, +# aggregates and trigger WHEN clauses are in the fixture because the routine and trigger reads only +# go wrong with them present. +# +# Run it against each server you care about, oldest first. Docker has images from 9.1 on: +# docker run -d --rm -e POSTGRES_PASSWORD=probe -p 127.0.0.1:54091:5432 postgres:9.1 +# PGPASSWORD=probe scripts/check-postgresql-compat.sh 127.0.0.1 54091 +# +# Usage: scripts/check-postgresql-compat.sh [host] [port] [--keep] +# Environment: PGUSER (default postgres, must be a superuser), PGPASSWORD, PSQL. +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOST="${1:-127.0.0.1}" +PORT="${2:-5432}" +KEEP=0 +[[ "${3:-}" == "--keep" ]] && KEEP=1 +OWNER="${PGUSER:-postgres}" +DATABASE="tablepro_compat_check" +READER="tablepro_compat_reader" +READER_PASSWORD="tablepro_compat_reader" + +WORK="$(mktemp -d)" +[[ "$KEEP" == "1" ]] && echo "Working directory: $WORK" + +PSQL="${PSQL:-$(command -v psql || true)}" +if [[ -z "$PSQL" ]]; then + echo "FAIL: psql not found; set PSQL" >&2 + exit 2 +fi + +run_psql() { + "$PSQL" -X -q -v ON_ERROR_STOP=1 -h "$HOST" -p "$PORT" -U "$OWNER" "$@" +} + +if ! run_psql -d postgres -Atc 'SELECT 1' > /dev/null 2>&1; then + echo "FAIL: cannot connect to $HOST:$PORT as $OWNER" >&2 + exit 2 +fi +SERVER_VERSION="$(run_psql -d postgres -Atc "SELECT current_setting('server_version_num')")" +echo "Checking against PostgreSQL $(run_psql -d postgres -Atc 'SHOW server_version') ($SERVER_VERSION) at $HOST:$PORT" + +# Reports why it could not drop rather than hiding it: the CREATE DATABASE that follows would fail +# with "already exists", which names neither the leftover fixture nor the session still holding it. +drop_fixture() { + local output + if ! output="$(run_psql -d postgres -c "DROP DATABASE IF EXISTS $DATABASE" 2>&1)"; then + echo "$output" >&2 + return 1 + fi + if ! output="$(run_psql -d postgres -c "DROP ROLE IF EXISTS $READER" 2>&1)"; then + echo "$output" >&2 + return 1 + fi +} +cleanup() { + drop_fixture || echo "warning: fixture left behind; drop $DATABASE and $READER by hand" >&2 + [[ "$KEEP" == "1" ]] || rm -rf "$WORK" +} +trap cleanup EXIT + +if [[ -z "${DEVELOPER_DIR:-}" ]]; then + for candidate in /Applications/Xcode-beta.app/Contents/Developer /Applications/Xcode.app/Contents/Developer; do + if [[ -d "$candidate/usr/bin" ]]; then + export DEVELOPER_DIR="$candidate" + break + fi + done +fi + +KIT_DIR="$REPO_ROOT/Plugins/TableProPluginKit" +DRIVER_DIR="$REPO_ROOT/Plugins/PostgreSQLDriverPlugin" +LIBS="$REPO_ROOT/Libs" +for required in "$LIBS/libpq.a" "$LIBS/libpgcommon.a" "$LIBS/libpgport.a" "$LIBS/dylibs/libssl.3.dylib"; do + if [[ ! -e "$required" ]]; then + echo "FAIL: missing $required; run scripts/download-libs.sh" >&2 + exit 2 + fi +done + +# The driver class reaches every file in its folder, so the folder is the unit rather than a list. +KIT_SOURCES=() +while IFS= read -r file; do KIT_SOURCES+=("$file"); done < <(find "$KIT_DIR" -name '*.swift' | sort) +DRIVER_SOURCES=() +while IFS= read -r file; do DRIVER_SOURCES+=("$file"); done < <(find "$DRIVER_DIR" -maxdepth 1 -name '*.swift' | sort) + +cat > "$WORK/harness.swift" <<'SWIFT' +import Foundation +import TableProPluginKit + +@main +enum CompatHarness { + static func main() async { + let arguments = CommandLine.arguments + guard arguments.count >= 7 else { + FileHandle.standardError.write(Data("usage: harness host port user password database owner|reader\n".utf8)) + exit(2) + } + let config = DriverConnectionConfig( + host: arguments[1], + port: Int(arguments[2]) ?? 5432, + username: arguments[3], + password: arguments[4], + database: arguments[5] + ) + let driver = PostgreSQLPluginDriver(config: config) + do { + try await driver.connect() + } catch { + print("error|connect|\(error.localizedDescription)") + exit(1) + } + if arguments[6] == "reader" { + await readerPass(driver) + } else { + await ownerPass(driver) + } + driver.disconnect() + } + + static func line(_ fields: [String]) { + print(fields.map { $0.replacingOccurrences(of: "\n", with: "\\n") }.joined(separator: "|")) + } + + static func attempt(_ label: String, _ body: () async throws -> Void) async { + do { + try await body() + } catch { + line(["error", label, error.localizedDescription]) + } + } + + static func ownerPass(_ driver: PostgreSQLPluginDriver) async { + await attempt("fetchTables") { + for table in try await driver.fetchTables(schema: "public") { + line(["table", table.name, table.type, table.comment ?? "-"]) + } + } + await attempt("fetchAllForeignKeys") { + let all = try await driver.fetchAllForeignKeys(schema: "public") + for (table, keys) in all.sorted(by: { $0.key < $1.key }) { + for (name, pairs) in foreignKeyPairs(keys) { + line(["fk", table, name, pairs]) + } + } + } + await attempt("fetchForeignKeys") { + for (name, pairs) in foreignKeyPairs(try await driver.fetchForeignKeys(table: "fk_child", schema: "public")) { + line(["fk-table", "fk_child", name, pairs]) + } + for (name, pairs) in foreignKeyPairs(try await driver.fetchForeignKeys(table: "part_child", schema: "public")) { + line(["fk-table", "part_child", name, pairs]) + } + } + await attempt("fetchColumns") { + for column in try await driver.fetchColumns(table: "orders", schema: "public") { + line([ + "column", "orders", column.name, column.dataType, + column.isNullable ? "null" : "not-null", column.isPrimaryKey ? "pk" : "-", + column.defaultValue ?? "-", column.comment ?? "-", + column.isIdentity ? "identity" : "-", column.isGenerated ? "generated" : "-" + ]) + } + } + await attempt("fetchAllColumns") { + let all = try await driver.fetchAllColumns(schema: "public") + for (table, columns) in all.sorted(by: { $0.key < $1.key }) { + line(["columns", table, columns.map(\.name).joined(separator: ";")]) + } + } + await attempt("fetchTableDDL") { + for table in ["orders", "Mixed Case", "we,ird"] { + let ddl = try await driver.fetchTableDDL(table: table, schema: "public") + let columns = try await driver.fetchColumns(table: table, schema: "public") + line(["ddl", table, ddlShape(ddl, columns: columns)]) + } + } + await attempt("fetchAllIndexes") { + let all = try await driver.fetchAllIndexes(schema: "public") + for (table, indexes) in all.sorted(by: { $0.key < $1.key }) { + for index in indexes { + line([ + "index", table, index.name, index.columns.joined(separator: ";"), + index.isUnique ? "unique" : "-", index.isPrimary ? "primary" : "-", index.type, + index.whereClause ?? "-" + ]) + } + } + } + await attempt("fetchIndexes") { + for index in try await driver.fetchIndexes(table: "idx_t", schema: "public") { + line(["index-table", "idx_t", index.name, index.columns.joined(separator: ";")]) + } + } + await attempt("fetchCheckConstraints") { + for check in try await driver.fetchCheckConstraints(table: "we,ird", schema: "public") { + line([ + "check", "we,ird", check.name, check.expression, check.columns.joined(separator: ";"), + check.isValidated ? "validated" : "not-validated" + ]) + } + } + await attempt("fetchAllTriggers") { + for trigger in try await driver.fetchTriggers(table: "orders", schema: "public") { + line([ + "trigger", trigger.table ?? "-", trigger.name, trigger.timing, trigger.event, + trigger.orientation ?? "-", trigger.enabled == true ? "enabled" : "disabled" + ]) + if trigger.name == "orders_touch" { + line(["trigger-when", trigger.name, (trigger.definition ?? "").contains(" WHEN ") ? "yes" : "no"]) + } + } + } + await attempt("fetchUserDefinedTypes") { + for type in try await driver.fetchUserDefinedTypes(schema: "public") { + line(["type", type.name, type.definition ?? "-"]) + } + } + await attempt("fetchRoutines") { + for routine in try await driver.fetchRoutines(schema: "public") { + line(["routine", routine.name, routine.argumentSignature ?? "-"]) + guard routine.name == "transform", let signature = routine.argumentSignature else { continue } + let ddl = try await driver.fetchRoutineDDL(routine) + line(["routine-ddl", signature, ddl.contains("transform\(signature)") ? "yes" : "no"]) + } + } + await attempt("fetchSequences") { + for sequence in try await driver.fetchSequences(schema: "public") { + line(["sequence", sequence.name, sequence.ddl]) + } + } + await attempt("fetchDependentSequences") { + for sequence in try await driver.fetchDependentSequences(table: "orders", schema: "public") { + line(["dependent-sequence", "orders", sequence.name, sequence.ddl]) + } + } + await attempt("fetchGrants") { + let grants = try await driver.fetchGrants(for: PluginPrincipalRef(name: "tablepro_compat_reader")) + for grant in grants.map({ "\($0.scope)|\($0.privilege)" }).sorted() { + print("grant|\(grant)") + } + } + await attempt("createDatabaseFormSpec") { + let template = try await driver.execute(query: "SELECT datcollate FROM pg_database WHERE datname = 'template1'") + let collate = template.rows.first?.first?.asText ?? "" + let spec = try await driver.createDatabaseFormSpec() + var hasTemplateCollation = false + if let field = spec?.fields.first(where: { $0.id == "collation" }), + case .searchable(let options, _) = field.kind { + hasTemplateCollation = options.contains { $0.value == collate } + } + line(["collations-include-template1", hasTemplateCollation ? "yes" : "no"]) + } + await attempt("allTablesMetadataSQL") { + guard let sql = driver.allTablesMetadataSQL(schema: "public") else { return } + let result = try await driver.execute(query: sql) + for row in result.rows where row[safe: 1]?.asText == "Mixed Case" { + line(["all-tables", "Mixed Case", row[safe: 7]?.asText ?? "-"]) + } + } + await attempt("fetchTableMetadata") { + let metadata = try await driver.fetchTableMetadata(table: "Mixed Case", schema: "public") + line(["metadata", "Mixed Case", metadata.comment ?? "-"]) + } + await attempt("fetchViewDefinition") { + let ddl = try await driver.fetchViewDefinition(view: "order_view", schema: "public") + line(["view", (ddl.components(separatedBy: "\n").first ?? "-").trimmingCharacters(in: .whitespaces)]) + } + await attempt("fetchViewDefinition(materialized)") { + let tables = try await driver.fetchTables(schema: "public") + guard tables.contains(where: { $0.name == "order_totals" }) else { return } + let ddl = try await driver.fetchViewDefinition(view: "order_totals", schema: "public") + line(["view", (ddl.components(separatedBy: "\n").first ?? "-").trimmingCharacters(in: .whitespaces)]) + } + } + + static func readerPass(_ driver: PostgreSQLPluginDriver) async { + await attempt("fetchTables") { + line(["reader-tables", "\(try await driver.fetchTables(schema: "public").count > 0)"]) + } + await attempt("fetchAllForeignKeys") { + line(["reader-fks", "\(try await driver.fetchAllForeignKeys(schema: "public").count > 0)"]) + } + await attempt("fetchUserDefinedTypes") { + line(["reader-types", "\(try await driver.fetchUserDefinedTypes(schema: "public").count > 0)"]) + } + await attempt("fetchSequences") { + for sequence in try await driver.fetchSequences(schema: "public") { + line(["reader-sequence", sequence.name, sequence.ddl]) + } + } + } + + /// The statement text differs by release (serial versus identity, how defaults print), so the + /// check pins what every release must produce: a terminated CREATE TABLE naming every column + /// the column read returned, with the primary key carried over. + static func ddlShape(_ ddl: String, columns: [PluginColumnInfo]) -> String { + let named = columns.allSatisfy { column in + let quoted = "\"\(column.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + return ddl.contains("\n \(quoted) ") || ddl.contains("\n \(column.name) ") + } + return [ + ddl.hasPrefix("CREATE TABLE ") ? "create" : "no-create", + named ? "names-all-columns" : "misses-a-column", + columns.contains(where: \.isPrimaryKey) == ddl.contains("PRIMARY KEY") ? "pk-consistent" : "pk-lost", + ddl.contains(";") ? "terminated" : "unterminated" + ].joined(separator: " ") + } + + static func foreignKeyPairs(_ keys: [PluginForeignKeyInfo]) -> [(String, String)] { + var order: [String] = [] + var pairs: [String: [String]] = [:] + for key in keys { + if pairs[key.name] == nil { order.append(key.name) } + pairs[key.name, default: []].append("\(key.column)>\(key.referencedTable).\(key.referencedColumn) \(key.onDelete)/\(key.onUpdate)") + } + return order.sorted().map { ($0, pairs[$0, default: []].joined(separator: ",")) } + } +} +SWIFT + +echo "Building the harness from the shipping sources..." +BUILD="$WORK/build" +mkdir -p "$BUILD" +if ! xcrun swiftc -emit-library -emit-module -module-name TableProPluginKit -parse-as-library -swift-version 6 \ + -enable-library-evolution -Xlinker -install_name -Xlinker @rpath/libTableProPluginKit.dylib \ + -emit-module-path "$BUILD/TableProPluginKit.swiftmodule" -o "$BUILD/libTableProPluginKit.dylib" \ + "${KIT_SOURCES[@]}" > "$WORK/build-kit.log" 2>&1; then + echo "FAIL: TableProPluginKit did not build" >&2 + grep -E "error" "$WORK/build-kit.log" | head -20 >&2 + exit 2 +fi +if ! xcrun swiftc -parse-as-library -swift-version 6 -module-name PostgreSQLCompatHarness \ + -I "$BUILD" -L "$BUILD" -lTableProPluginKit \ + -I "$DRIVER_DIR/CLibPQ" -Xcc -I"$DRIVER_DIR/CLibPQ/include" \ + -Xlinker -force_load -Xlinker "$LIBS/libpq.a" "$LIBS/libpgcommon.a" "$LIBS/libpgport.a" \ + -L "$LIBS/dylibs" -lssl.3 -lcrypto.3 -lz \ + -Xlinker -rpath -Xlinker "$BUILD" -Xlinker -rpath -Xlinker "$LIBS/dylibs" \ + -o "$BUILD/harness" "${DRIVER_SOURCES[@]}" "$WORK/harness.swift" > "$WORK/build-harness.log" 2>&1; then + echo "FAIL: the driver harness did not build" >&2 + grep -E "error" "$WORK/build-harness.log" | head -20 >&2 + exit 2 +fi + +if ! drop_fixture; then + echo "FAIL: a leftover fixture could not be dropped; another session is probably connected to $DATABASE" >&2 + exit 2 +fi +run_psql -d postgres -c "CREATE DATABASE $DATABASE" > /dev/null +run_psql -d postgres -c "CREATE ROLE $READER LOGIN PASSWORD '$READER_PASSWORD'" > /dev/null + +run_psql -d "$DATABASE" > "$WORK/fixture.log" 2>&1 <= 90200 AS pg92, + current_setting('server_version_num')::int >= 90300 AS pg93, + current_setting('server_version_num')::int >= 100000 AS pg10, + current_setting('server_version_num')::int >= 110000 AS pg11, + current_setting('server_version_num')::int >= 120000 AS pg12 \gset + +CREATE TYPE mood AS ENUM ('very happy', 'sad,ish', 'NULL', 'q"t', ''); +CREATE TYPE "odd,type" AS ("first name" text COLLATE "C", "q""t" integer, zip varchar(10)); +CREATE DOMAIN checked AS text CONSTRAINT "has,comma" CHECK (VALUE <> '') CONSTRAINT b_len CHECK (length(VALUE) < 10); + +CREATE TABLE customers (id bigserial PRIMARY KEY, email text NOT NULL UNIQUE); +COMMENT ON TABLE customers IS 'Customer accounts'; +CREATE TABLE orders ( + id bigserial PRIMARY KEY, + customer_id bigint NOT NULL REFERENCES customers (id) ON DELETE CASCADE, + qty integer, + status mood +); +COMMENT ON TABLE orders IS 'Orders'; +INSERT INTO customers (email) VALUES ('a@x'), ('b@x'); +INSERT INTO orders (customer_id, qty) VALUES (1, 150), (2, 5); +CREATE INDEX orders_big_qty ON orders (qty) WHERE qty > 100; +CREATE INDEX orders_multi ON orders (qty, customer_id); + +CREATE TABLE fk_parent (a integer, b integer, PRIMARY KEY (a, b)); +CREATE TABLE fk_child (x integer, y integer, CONSTRAINT fk_rev FOREIGN KEY (y, x) REFERENCES fk_parent (b, a) ON UPDATE CASCADE); + +CREATE TABLE idx_t (a integer, b text, c integer, "d,e" integer); +CREATE INDEX idx_dup ON idx_t (a, a); +CREATE INDEX idx_mixed ON idx_t (c, lower(b), a); +CREATE INDEX idx_rev ON idx_t (c, a); +CREATE INDEX idx_weird ON idx_t ("d,e", b); + +CREATE TABLE "we,ird" ("a b" integer, "c,d" integer, "q""t" integer, + CONSTRAINT "we,ird_chk" CHECK ("a b" + "c,d" + "q""t" > 0), CONSTRAINT constant_chk CHECK (1 > 0)); + +CREATE TABLE "Mixed Case" ("Id" integer PRIMARY KEY); +COMMENT ON TABLE "Mixed Case" IS 'Mixed comment'; + +CREATE VIEW order_view AS SELECT id, qty FROM orders; +COMMENT ON VIEW order_view IS 'Order view'; + +CREATE SEQUENCE compat_seq INCREMENT 2 MINVALUE 5 MAXVALUE 50 START 7 CYCLE; +SELECT nextval('compat_seq'); +CREATE SEQUENCE hidden_seq MAXVALUE 99; + +CREATE FUNCTION transform(a integer) RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT \$1'; +CREATE FUNCTION transform(a text) RETURNS integer LANGUAGE sql AS 'SELECT 2'; +CREATE AGGREGATE my_sum(integer) (SFUNC = int4pl, STYPE = integer); +CREATE FUNCTION touch() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RETURN NEW; END \$\$; +CREATE TRIGGER orders_touch BEFORE INSERT OR UPDATE ON orders FOR EACH ROW WHEN (NEW.qty > 0) EXECUTE PROCEDURE touch(); +CREATE TRIGGER orders_audit AFTER DELETE OR TRUNCATE ON orders FOR EACH STATEMENT EXECUTE PROCEDURE touch(); +ALTER TABLE orders DISABLE TRIGGER orders_audit; + +CREATE FOREIGN DATA WRAPPER compat_fdw; +CREATE SERVER compat_srv FOREIGN DATA WRAPPER compat_fdw; +CREATE FOREIGN TABLE remote_things (id integer) SERVER compat_srv; +COMMENT ON FOREIGN TABLE remote_things IS 'Remote comment'; + +GRANT CONNECT ON DATABASE $DATABASE TO $READER; +GRANT USAGE ON SCHEMA public TO $READER; +GRANT SELECT ON customers TO $READER; +GRANT SELECT (qty), UPDATE (qty) ON orders TO $READER; +GRANT USAGE ON SEQUENCE compat_seq TO $READER; + +\if :pg92 +ALTER TABLE "we,ird" ADD CONSTRAINT late_chk CHECK ("a b" > 0) NOT VALID; +\endif + +\if :pg93 +CREATE MATERIALIZED VIEW order_totals AS SELECT customer_id, sum(qty) AS total FROM orders GROUP BY customer_id; +COMMENT ON MATERIALIZED VIEW order_totals IS 'Totals'; +\endif + +\if :pg10 +CREATE TABLE events (happened date NOT NULL) PARTITION BY RANGE (happened); +CREATE TABLE events_2024 PARTITION OF events FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +\endif + +\if :pg11 +CREATE PROCEDURE reset_qty() LANGUAGE sql AS 'UPDATE orders SET qty = 0'; +\endif + +\if :pg12 +CREATE TABLE part_ref (id integer PRIMARY KEY) PARTITION BY RANGE (id); +CREATE TABLE part_ref_lo PARTITION OF part_ref FOR VALUES FROM (1) TO (100); +CREATE TABLE part_ref_hi PARTITION OF part_ref FOR VALUES FROM (100) TO (200); +CREATE TABLE part_child (ref_id integer REFERENCES part_ref (id)); +CREATE TABLE part_src (id integer NOT NULL, cust bigint REFERENCES customers (id)) PARTITION BY RANGE (id); +CREATE TABLE part_src_1 PARTITION OF part_src FOR VALUES FROM (1) TO (10); +\endif +SQL + +# One expected line per object. A leading "min-max|" limits a line to servers in that range of +# server_version_num; "-" leaves an end open. +cat > "$WORK/expected.txt" <<'EXPECTED' +-|table|Mixed Case|TABLE|Mixed comment +-|table|customers|TABLE|Customer accounts +-|table|fk_child|TABLE|- +-|table|fk_parent|TABLE|- +-|table|idx_t|TABLE|- +-|table|order_view|VIEW|Order view +-|table|orders|TABLE|Orders +-|table|remote_things|FOREIGN TABLE|Remote comment +-|table|we,ird|TABLE|- +90300-|table|order_totals|MATERIALIZED VIEW|Totals +100000-|table|events|PARTITIONED TABLE|- +120000-|table|part_child|TABLE|- +120000-|table|part_ref|PARTITIONED TABLE|- +120000-|table|part_src|PARTITIONED TABLE|- +-|fk|fk_child|fk_rev|y>fk_parent.b NO ACTION/CASCADE,x>fk_parent.a NO ACTION/CASCADE +-|fk|orders|orders_customer_id_fkey|customer_id>customers.id CASCADE/NO ACTION +120000-|fk|part_child|part_child_ref_id_fkey|ref_id>part_ref.id NO ACTION/NO ACTION +120000-|fk|part_src|part_src_cust_fkey|cust>customers.id NO ACTION/NO ACTION +120000-|fk|part_src_1|part_src_cust_fkey|cust>customers.id NO ACTION/NO ACTION +-|fk-table|fk_child|fk_rev|y>fk_parent.b NO ACTION/CASCADE,x>fk_parent.a NO ACTION/CASCADE +120000-|fk-table|part_child|part_child_ref_id_fkey|ref_id>part_ref.id NO ACTION/NO ACTION +-|index|Mixed Case|Mixed Case_pkey|Id|unique|primary|BTREE|- +-|index|customers|customers_pkey|id|unique|primary|BTREE|- +-|index|customers|customers_email_key|email|unique|-|BTREE|- +-|index|fk_parent|fk_parent_pkey|a;b|unique|primary|BTREE|- +-|index|idx_t|idx_dup|a|-|-|BTREE|- +-|column|orders|customer_id|BIGINT|not-null|-|-|-|-|- +-|column|orders|id|BIGINT|not-null|pk|nextval('orders_id_seq'::regclass)|-|-|- +-|column|orders|qty|INTEGER|null|-|-|-|-|- +-|column|orders|status|ENUM|null|-|-|-|-|- +-|columns|Mixed Case|Id +-|columns|customers|id;email +-|columns|fk_child|x;y +-|columns|fk_parent|a;b +-|columns|idx_t|a;b;c;d,e +-|columns|order_view|id;qty +-|columns|orders|id;customer_id;qty;status +-|columns|remote_things|id +-|columns|we,ird|a b;c,d;q"t +100000-|columns|events|happened +100000-|columns|events_2024|happened +120000-|columns|part_child|ref_id +120000-|columns|part_ref|id +120000-|columns|part_ref_hi|id +120000-|columns|part_ref_lo|id +120000-|columns|part_src|id;cust +120000-|columns|part_src_1|id;cust +-|ddl|Mixed Case|create names-all-columns pk-consistent terminated +-|ddl|orders|create names-all-columns pk-consistent terminated +-|ddl|we,ird|create names-all-columns pk-consistent terminated +120000-|index|part_ref|part_ref_pkey|id|unique|primary|BTREE|- +120000-|index|part_ref_hi|part_ref_hi_pkey|id|unique|primary|BTREE|- +120000-|index|part_ref_lo|part_ref_lo_pkey|id|unique|primary|BTREE|- +-|index|idx_t|idx_mixed|c;a|-|-|BTREE|- +-|index|idx_t|idx_rev|c;a|-|-|BTREE|- +-|index|idx_t|idx_weird|d,e;b|-|-|BTREE|- +-|index|orders|orders_pkey|id|unique|primary|BTREE|- +-|index|orders|orders_big_qty|qty|-|-|BTREE|(qty > 100) +-|index|orders|orders_multi|qty;customer_id|-|-|BTREE|- +-|index-table|idx_t|idx_dup|a +-|index-table|idx_t|idx_mixed|c;a +-|index-table|idx_t|idx_rev|c;a +-|index-table|idx_t|idx_weird|d,e;b +-|check|we,ird|constant_chk|1 > 0||validated +-|check|we,ird|we,ird_chk|(("a b" + "c,d") + "q""t") > 0|a b;c,d;q"t|validated +90200-|check|we,ird|late_chk|"a b" > 0|a b|not-validated +-|trigger|orders|orders_audit|AFTER|DELETE OR TRUNCATE|STATEMENT|disabled +-|trigger|orders|orders_touch|BEFORE|INSERT OR UPDATE|ROW|enabled +-|type|checked|CREATE DOMAIN "public"."checked" AS text\n CONSTRAINT "b_len" CHECK ((length(VALUE) < 10))\n CONSTRAINT "has,comma" CHECK ((VALUE <> ''::text)); +-|type|mood|CREATE TYPE "public"."mood" AS ENUM (\n 'very happy',\n 'sad,ish',\n 'NULL',\n 'q"t',\n ''\n); +-|type|odd,type|CREATE TYPE "public"."odd,type" AS (\n "first name" text COLLATE pg_catalog."C",\n "q""t" integer,\n "zip" character varying(10)\n); +-|routine|touch|() +-|routine-ddl|(a integer)|yes +-|routine-ddl|(a text)|yes +-|trigger-when|orders_touch|yes +-|routine|transform|(a integer) +-|routine|transform|(a text) +110000-|routine|reset_qty|() +-|sequence|compat_seq|CREATE SEQUENCE "compat_seq" INCREMENT BY 2 MINVALUE 5 MAXVALUE 50 START WITH 7 CYCLE;\nSELECT pg_catalog.setval('"compat_seq"', 7, true); +-|sequence|customers_id_seq|CREATE SEQUENCE "customers_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1;\nSELECT pg_catalog.setval('"customers_id_seq"', 2, true); +-|sequence|hidden_seq|CREATE SEQUENCE "hidden_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 99 START WITH 1; +-|sequence|orders_id_seq|CREATE SEQUENCE "orders_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1;\nSELECT pg_catalog.setval('"orders_id_seq"', 2, true); +-|dependent-sequence|orders|orders_id_seq|CREATE SEQUENCE "orders_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1;\nSELECT pg_catalog.setval('"orders_id_seq"', 2, true); +-|grant|column(database: "tablepro_compat_check", schema: Optional("public"), table: "orders", column: "qty")|SELECT +-|grant|column(database: "tablepro_compat_check", schema: Optional("public"), table: "orders", column: "qty")|UPDATE +-|grant|database("tablepro_compat_check")|CONNECT +-|grant|schema(database: "tablepro_compat_check", schema: "public")|USAGE +-|grant|table(database: "tablepro_compat_check", schema: Optional("public"), table: "customers")|SELECT +-|collations-include-template1|yes +-|all-tables|Mixed Case|Mixed comment +-|metadata|Mixed Case|Mixed comment +-|view|CREATE OR REPLACE VIEW "public"."order_view" AS +90300-|view|CREATE MATERIALIZED VIEW "public"."order_totals" AS +EXPECTED + +cat > "$WORK/expected-reader.txt" <<'EXPECTED' +-|reader-tables|true +-|reader-fks|true +-|reader-types|true +-99999|reader-sequence|customers_id_seq|CREATE SEQUENCE "customers_id_seq"; +-99999|reader-sequence|orders_id_seq|CREATE SEQUENCE "orders_id_seq"; +-99999|reader-sequence|hidden_seq|CREATE SEQUENCE "hidden_seq"; +100000-|reader-sequence|customers_id_seq|CREATE SEQUENCE "customers_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1; +100000-|reader-sequence|orders_id_seq|CREATE SEQUENCE "orders_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1; +100000-|reader-sequence|hidden_seq|CREATE SEQUENCE "hidden_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 99 START WITH 1; +-99999|reader-sequence|compat_seq|CREATE SEQUENCE "compat_seq" INCREMENT BY 2 MINVALUE 5 MAXVALUE 50 START WITH 7 CYCLE; +100000-|reader-sequence|compat_seq|CREATE SEQUENCE "compat_seq" INCREMENT BY 2 MINVALUE 5 MAXVALUE 50 START WITH 7 CYCLE;\nSELECT pg_catalog.setval('"compat_seq"', 7, true); +EXPECTED + +select_for_version() { + awk -F'|' -v version="$SERVER_VERSION" '{ + range = $1 + split(range, bounds, "-") + low = bounds[1] == "" ? 0 : bounds[1] + 0 + high = bounds[2] == "" ? 999999999 : bounds[2] + 0 + if (range == "-") { low = 0; high = 999999999 } + if (version + 0 >= low && version + 0 <= high) { + sub(/^[^|]*\|/, "") + print + } + }' "$1" | LC_ALL=C sort +} + +failures=0 +compare() { + local label="$1" expected="$2" actual="$3" + if ! diff -u "$expected" "$actual" > "$WORK/$label.diff"; then + echo "FAIL: $label reads disagree with the expected answers (- expected, + driver):" >&2 + tail -n +3 "$WORK/$label.diff" >&2 + failures=$((failures + 1)) + fi +} + +# The harness prints one line per object and exits non-zero when it cannot even connect. Piping it +# straight into sort would let pipefail end the run through the EXIT trap, which deletes the working +# directory: the script whose job is diagnosis would print nothing to diagnose with. +run_pass() { + local pass="$1" user="$2" password="$3" out="$4" status=0 + "$BUILD/harness" "$HOST" "$PORT" "$user" "$password" "$DATABASE" "$pass" \ + > "$WORK/$pass.raw" 2> "$WORK/$pass.err" || status=$? + if [[ "$status" -ne 0 ]]; then + echo "FAIL: the $pass harness pass exited $status" >&2 + tail -n 20 "$WORK/$pass.err" >&2 + tail -n 5 "$WORK/$pass.raw" >&2 + KEEP=1 + echo "Working directory kept for diagnosis: $WORK" >&2 + return 1 + fi + LC_ALL=C sort "$WORK/$pass.raw" > "$out" +} + +PGPASSWORD_OWNER="${PGPASSWORD:-}" +if run_pass owner "$OWNER" "$PGPASSWORD_OWNER" "$WORK/actual.txt"; then + select_for_version "$WORK/expected.txt" > "$WORK/expected-selected.txt" + compare owner "$WORK/expected-selected.txt" "$WORK/actual.txt" +else + failures=$((failures + 1)) +fi + +if run_pass reader "$READER" "$READER_PASSWORD" "$WORK/actual-reader.txt"; then + select_for_version "$WORK/expected-reader.txt" > "$WORK/expected-reader-selected.txt" + compare reader "$WORK/expected-reader-selected.txt" "$WORK/actual-reader.txt" +else + failures=$((failures + 1)) +fi + +if [[ "$failures" -gt 0 ]]; then + echo "$failures pass(es) disagreed on PostgreSQL $SERVER_VERSION" >&2 + exit 1 +fi +echo "PostgreSQL driver catalog reads agree on PostgreSQL $SERVER_VERSION"